diff options
author | Stuart McLaren <stuart.mclaren@hp.com> | 2015-04-17 14:02:33 +0000 |
---|---|---|
committer | Stuart McLaren <stuart.mclaren@hp.com> | 2015-04-18 17:42:20 +0000 |
commit | f2a8a520e76a129039b3c4043aeb8db75582b8c8 (patch) | |
tree | 1bdef9cffd95d747c515d16e6ea0bcea90cf8b54 /glanceclient | |
parent | 825c4a5df2e32a2d7c1665f0924cc5b9fa675673 (diff) | |
download | python-glanceclient-f2a8a520e76a129039b3c4043aeb8db75582b8c8.tar.gz |
Move unit tests to standard directory
This patch moves the glanceclient unit tests to the standard directory
(xxxclient/tests/unit) in preparation for adding functional gate tests
'check-glanceclient-dsvm-functional' in the same vein as existing client
tests for other projects, eg:
* check-novaclient-dsvm-functional
* check-keystoneclient-dsvm-functional
* check-neutronclient-dsvm-functional
Change-Id: I29d4b9e3a428c851575ee9afde40d6df583456c4
Diffstat (limited to 'glanceclient')
32 files changed, 8202 insertions, 0 deletions
diff --git a/glanceclient/tests/__init__.py b/glanceclient/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/glanceclient/tests/__init__.py diff --git a/glanceclient/tests/unit/__init__.py b/glanceclient/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/glanceclient/tests/unit/__init__.py diff --git a/glanceclient/tests/unit/test_base.py b/glanceclient/tests/unit/test_base.py new file mode 100644 index 0000000..4a97de8 --- /dev/null +++ b/glanceclient/tests/unit/test_base.py @@ -0,0 +1,57 @@ +# Copyright 2013 OpenStack Foundation +# Copyright (C) 2013 Yahoo! 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 testtools + +from glanceclient.openstack.common.apiclient import base + + +class TestBase(testtools.TestCase): + + def test_resource_repr(self): + r = base.Resource(None, dict(foo="bar", baz="spam")) + self.assertEqual("<Resource baz=spam, foo=bar>", repr(r)) + + def test_getid(self): + self.assertEqual(4, base.getid(4)) + + class TmpObject(object): + id = 4 + self.assertEqual(4, base.getid(TmpObject)) + + def test_two_resources_with_same_id_are_equal(self): + # Two resources of the same type with the same id: equal + r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) + r2 = base.Resource(None, {'id': 1, 'name': 'hello'}) + self.assertEqual(r1, r2) + + def test_two_resources_with_eq_info_are_equal(self): + # Two resources with no ID: equal if their info is equal + r1 = base.Resource(None, {'name': 'joe', 'age': 12}) + r2 = base.Resource(None, {'name': 'joe', 'age': 12}) + self.assertEqual(r1, r2) + + def test_two_resources_with_diff_id_are_not_equal(self): + # Two resources with diff ID: not equal + r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) + r2 = base.Resource(None, {'id': 2, 'name': 'hello'}) + self.assertNotEqual(r1, r2) + + def test_two_resources_with_not_eq_info_are_not_equal(self): + # Two resources with no ID: not equal if their info is not equal + r1 = base.Resource(None, {'name': 'bill', 'age': 21}) + r2 = base.Resource(None, {'name': 'joe', 'age': 12}) + self.assertNotEqual(r1, r2) diff --git a/glanceclient/tests/unit/test_client.py b/glanceclient/tests/unit/test_client.py new file mode 100644 index 0000000..62daaf5 --- /dev/null +++ b/glanceclient/tests/unit/test_client.py @@ -0,0 +1,46 @@ +# Copyright 2014 Red Hat, 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 testtools + +from glanceclient import client +from glanceclient.v1 import client as v1 +from glanceclient.v2 import client as v2 + + +class ClientTest(testtools.TestCase): + + def test_no_endpoint_error(self): + self.assertRaises(ValueError, client.Client, None) + + def test_endpoint(self): + gc = client.Client(1, "http://example.com") + self.assertEqual("http://example.com", gc.http_client.endpoint) + self.assertIsInstance(gc, v1.Client) + + def test_versioned_endpoint(self): + gc = client.Client(1, "http://example.com/v2") + self.assertEqual("http://example.com", gc.http_client.endpoint) + self.assertIsInstance(gc, v1.Client) + + def test_versioned_endpoint_no_version(self): + gc = client.Client(endpoint="http://example.com/v2") + self.assertEqual("http://example.com", gc.http_client.endpoint) + self.assertIsInstance(gc, v2.Client) + + def test_versioned_endpoint_with_minor_revision(self): + gc = client.Client(2.2, "http://example.com/v2.1") + self.assertEqual("http://example.com", gc.http_client.endpoint) + self.assertIsInstance(gc, v2.Client) diff --git a/glanceclient/tests/unit/test_exc.py b/glanceclient/tests/unit/test_exc.py new file mode 100644 index 0000000..575c62b --- /dev/null +++ b/glanceclient/tests/unit/test_exc.py @@ -0,0 +1,70 @@ +# 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 mock +import testtools + +from glanceclient import exc + +HTML_MSG = """<html> + <head> + <title>404 Entity Not Found</title> + </head> + <body> + <h1>404 Entity Not Found</h1> + Entity could not be found + <br /><br /> + </body> +</html>""" + + +class TestHTTPExceptions(testtools.TestCase): + def test_from_response(self): + """exc.from_response should return instance of an HTTP exception.""" + mock_resp = mock.Mock() + mock_resp.status_code = 400 + out = exc.from_response(mock_resp) + self.assertIsInstance(out, exc.HTTPBadRequest) + + def test_handles_json(self): + """exc.from_response should not print JSON.""" + mock_resp = mock.Mock() + mock_resp.status_code = 413 + mock_resp.json.return_value = { + "overLimit": { + "code": 413, + "message": "OverLimit Retry...", + "details": "Error Details...", + "retryAt": "2014-12-03T13:33:06Z" + } + } + mock_resp.headers = { + "content-type": "application/json" + } + err = exc.from_response(mock_resp, "Non-empty body") + self.assertIsInstance(err, exc.HTTPOverLimit) + self.assertEqual("OverLimit Retry...", err.details) + + def test_handles_html(self): + """exc.from_response should not print HTML.""" + mock_resp = mock.Mock() + mock_resp.status_code = 404 + mock_resp.text = HTML_MSG + mock_resp.headers = { + "content-type": "text/html" + } + err = exc.from_response(mock_resp, HTML_MSG) + self.assertIsInstance(err, exc.HTTPNotFound) + self.assertEqual("404 Entity Not Found: Entity could not be found", + err.details) diff --git a/glanceclient/tests/unit/test_http.py b/glanceclient/tests/unit/test_http.py new file mode 100644 index 0000000..e8bfaaa --- /dev/null +++ b/glanceclient/tests/unit/test_http.py @@ -0,0 +1,326 @@ +# 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 json + +import mock +import requests +from requests_mock.contrib import fixture +import six +from six.moves.urllib import parse +import testtools +from testtools import matchers +import types + +import glanceclient +from glanceclient.common import http +from glanceclient.common import https +from glanceclient import exc +from glanceclient.tests import utils + + +class TestClient(testtools.TestCase): + + def setUp(self): + super(TestClient, self).setUp() + self.mock = self.useFixture(fixture.Fixture()) + + self.endpoint = 'http://example.com:9292' + self.ssl_endpoint = 'https://example.com:9292' + self.client = http.HTTPClient(self.endpoint, token=u'abc123') + + def test_identity_headers_and_token(self): + identity_headers = { + 'X-Auth-Token': 'auth_token', + 'X-User-Id': 'user', + 'X-Tenant-Id': 'tenant', + 'X-Roles': 'roles', + 'X-Identity-Status': 'Confirmed', + 'X-Service-Catalog': 'service_catalog', + } + #with token + kwargs = {'token': u'fake-token', + 'identity_headers': identity_headers} + http_client_object = http.HTTPClient(self.endpoint, **kwargs) + self.assertEqual('auth_token', http_client_object.auth_token) + self.assertTrue(http_client_object.identity_headers. + get('X-Auth-Token') is None) + + def test_identity_headers_and_no_token_in_header(self): + identity_headers = { + 'X-User-Id': 'user', + 'X-Tenant-Id': 'tenant', + 'X-Roles': 'roles', + 'X-Identity-Status': 'Confirmed', + 'X-Service-Catalog': 'service_catalog', + } + #without X-Auth-Token in identity headers + kwargs = {'token': u'fake-token', + 'identity_headers': identity_headers} + http_client_object = http.HTTPClient(self.endpoint, **kwargs) + self.assertEqual(u'fake-token', http_client_object.auth_token) + self.assertTrue(http_client_object.identity_headers. + get('X-Auth-Token') is None) + + def test_identity_headers_and_no_token_in_session_header(self): + # Tests that if token or X-Auth-Token are not provided in the kwargs + # when creating the http client, the session headers don't contain + # the X-Auth-Token key. + identity_headers = { + 'X-User-Id': 'user', + 'X-Tenant-Id': 'tenant', + 'X-Roles': 'roles', + 'X-Identity-Status': 'Confirmed', + 'X-Service-Catalog': 'service_catalog', + } + kwargs = {'identity_headers': identity_headers} + http_client_object = http.HTTPClient(self.endpoint, **kwargs) + self.assertIsNone(http_client_object.auth_token) + self.assertNotIn('X-Auth-Token', http_client_object.session.headers) + + def test_identity_headers_are_passed(self): + # Tests that if token or X-Auth-Token are not provided in the kwargs + # when creating the http client, the session headers don't contain + # the X-Auth-Token key. + identity_headers = { + 'X-User-Id': b'user', + 'X-Tenant-Id': b'tenant', + 'X-Roles': b'roles', + 'X-Identity-Status': b'Confirmed', + 'X-Service-Catalog': b'service_catalog', + } + kwargs = {'identity_headers': identity_headers} + http_client = http.HTTPClient(self.endpoint, **kwargs) + + path = '/v1/images/my-image' + self.mock.get(self.endpoint + path) + http_client.get(path) + + headers = self.mock.last_request.headers + for k, v in six.iteritems(identity_headers): + self.assertEqual(v, headers[k]) + + def test_connection_refused(self): + """ + Should receive a CommunicationError if connection refused. + And the error should list the host and port that refused the + connection + """ + def cb(request, context): + raise requests.exceptions.ConnectionError() + + path = '/v1/images/detail?limit=20' + self.mock.get(self.endpoint + path, text=cb) + + comm_err = self.assertRaises(glanceclient.exc.CommunicationError, + self.client.get, + '/v1/images/detail?limit=20') + + self.assertIn(self.endpoint, comm_err.message) + + def test_http_encoding(self): + path = '/v1/images/detail' + text = 'Ok' + self.mock.get(self.endpoint + path, text=text, + headers={"Content-Type": "text/plain"}) + + headers = {"test": u'ni\xf1o'} + resp, body = self.client.get(path, headers=headers) + self.assertEqual(text, resp.text) + + def test_headers_encoding(self): + value = u'ni\xf1o' + headers = {"test": value, "none-val": None} + encoded = self.client.encode_headers(headers) + self.assertEqual(b"ni\xc3\xb1o", encoded[b"test"]) + self.assertNotIn("none-val", encoded) + + def test_raw_request(self): + " Verify the path being used for HTTP requests reflects accurately. " + headers = {"Content-Type": "text/plain"} + text = 'Ok' + path = '/v1/images/detail' + + self.mock.get(self.endpoint + path, text=text, headers=headers) + + resp, body = self.client.get('/v1/images/detail', headers=headers) + self.assertEqual(headers, resp.headers) + self.assertEqual(text, resp.text) + + def test_parse_endpoint(self): + endpoint = 'http://example.com:9292' + test_client = http.HTTPClient(endpoint, token=u'adc123') + actual = test_client.parse_endpoint(endpoint) + expected = parse.SplitResult(scheme='http', + netloc='example.com:9292', path='', + query='', fragment='') + self.assertEqual(expected, actual) + + def test_get_connections_kwargs_http(self): + endpoint = 'http://example.com:9292' + test_client = http.HTTPClient(endpoint, token=u'adc123') + self.assertEqual(test_client.timeout, 600.0) + + def test_http_chunked_request(self): + text = "Ok" + data = six.StringIO(text) + path = '/v1/images/' + self.mock.post(self.endpoint + path, text=text) + + headers = {"test": u'chunked_request'} + resp, body = self.client.post(path, headers=headers, data=data) + self.assertIsInstance(self.mock.last_request.body, types.GeneratorType) + self.assertEqual(text, resp.text) + + def test_http_json(self): + data = {"test": "json_request"} + path = '/v1/images' + text = 'OK' + self.mock.post(self.endpoint + path, text=text) + + headers = {"test": u'chunked_request'} + resp, body = self.client.post(path, headers=headers, data=data) + + self.assertEqual(text, resp.text) + self.assertIsInstance(self.mock.last_request.body, six.string_types) + self.assertEqual(data, json.loads(self.mock.last_request.body)) + + def test_http_chunked_response(self): + data = "TEST" + path = '/v1/images/' + self.mock.get(self.endpoint + path, body=six.StringIO(data), + headers={"Content-Type": "application/octet-stream"}) + + resp, body = self.client.get(path) + self.assertTrue(isinstance(body, types.GeneratorType)) + self.assertEqual([data], list(body)) + + def test_log_http_response_with_non_ascii_char(self): + try: + response = 'Ok' + headers = {"Content-Type": "text/plain", + "test": "value1\xa5\xa6"} + fake = utils.FakeResponse(headers, six.StringIO(response)) + self.client.log_http_response(fake) + except UnicodeDecodeError as e: + self.fail("Unexpected UnicodeDecodeError exception '%s'" % e) + + def test_log_curl_request_with_non_ascii_char(self): + try: + headers = {'header1': 'value1\xa5\xa6'} + body = 'examplebody\xa5\xa6' + self.client.log_curl_request('GET', '/api/v1/\xa5', headers, body, + None) + except UnicodeDecodeError as e: + self.fail("Unexpected UnicodeDecodeError exception '%s'" % e) + + @mock.patch('glanceclient.common.http.LOG.debug') + def test_log_curl_request_with_body_and_header(self, mock_log): + hd_name = 'header1' + hd_val = 'value1' + headers = {hd_name: hd_val} + body = 'examplebody' + self.client.log_curl_request('GET', '/api/v1/', headers, body, None) + self.assertTrue(mock_log.called, 'LOG.debug never called') + self.assertTrue(mock_log.call_args[0], + 'LOG.debug called with no arguments') + hd_regex = ".*\s-H\s+'\s*%s\s*:\s*%s\s*'.*" % (hd_name, hd_val) + self.assertThat(mock_log.call_args[0][0], + matchers.MatchesRegex(hd_regex), + 'header not found in curl command') + body_regex = ".*\s-d\s+'%s'\s.*" % body + self.assertThat(mock_log.call_args[0][0], + matchers.MatchesRegex(body_regex), + 'body not found in curl command') + + def _test_log_curl_request_with_certs(self, mock_log, key, cert, cacert): + headers = {'header1': 'value1'} + http_client_object = http.HTTPClient(self.ssl_endpoint, key_file=key, + cert_file=cert, cacert=cacert, + token='fake-token') + http_client_object.log_curl_request('GET', '/api/v1/', headers, None, + None) + self.assertTrue(mock_log.called, 'LOG.debug never called') + self.assertTrue(mock_log.call_args[0], + 'LOG.debug called with no arguments') + + needles = {'key': key, 'cert': cert, 'cacert': cacert} + for option, value in six.iteritems(needles): + if value: + regex = ".*\s--%s\s+('%s'|%s).*" % (option, value, value) + self.assertThat(mock_log.call_args[0][0], + matchers.MatchesRegex(regex), + 'no --%s option in curl command' % option) + else: + regex = ".*\s--%s\s+.*" % option + self.assertThat(mock_log.call_args[0][0], + matchers.Not(matchers.MatchesRegex(regex)), + 'unexpected --%s option in curl command' % + option) + + @mock.patch('glanceclient.common.http.LOG.debug') + def test_log_curl_request_with_all_certs(self, mock_log): + self._test_log_curl_request_with_certs(mock_log, 'key1', 'cert1', + 'cacert2') + + @mock.patch('glanceclient.common.http.LOG.debug') + def test_log_curl_request_with_some_certs(self, mock_log): + self._test_log_curl_request_with_certs(mock_log, 'key1', 'cert1', None) + + @mock.patch('glanceclient.common.http.LOG.debug') + def test_log_curl_request_with_insecure_param(self, mock_log): + headers = {'header1': 'value1'} + http_client_object = http.HTTPClient(self.ssl_endpoint, insecure=True, + token='fake-token') + http_client_object.log_curl_request('GET', '/api/v1/', headers, None, + None) + self.assertTrue(mock_log.called, 'LOG.debug never called') + self.assertTrue(mock_log.call_args[0], + 'LOG.debug called with no arguments') + self.assertThat(mock_log.call_args[0][0], + matchers.MatchesRegex('.*\s-k\s.*'), + 'no -k option in curl command') + + @mock.patch('glanceclient.common.http.LOG.debug') + def test_log_curl_request_with_token_header(self, mock_log): + fake_token = 'fake-token' + headers = {'X-Auth-Token': fake_token} + http_client_object = http.HTTPClient(self.endpoint, + identity_headers=headers) + http_client_object.log_curl_request('GET', '/api/v1/', headers, None, + None) + self.assertTrue(mock_log.called, 'LOG.debug never called') + self.assertTrue(mock_log.call_args[0], + 'LOG.debug called with no arguments') + token_regex = '.*%s.*' % fake_token + self.assertThat(mock_log.call_args[0][0], + matchers.Not(matchers.MatchesRegex(token_regex)), + 'token found in LOG.debug parameter') + + +class TestVerifiedHTTPSConnection(testtools.TestCase): + """Test fixture for glanceclient.common.http.VerifiedHTTPSConnection.""" + + def test_setcontext_unable_to_load_cacert(self): + """Add this UT case with Bug#1265730.""" + self.assertRaises(exc.SSLConfigurationError, + https.VerifiedHTTPSConnection, + "127.0.0.1", + None, + None, + None, + "gx_cacert", + None, + False, + True) diff --git a/glanceclient/tests/unit/test_progressbar.py b/glanceclient/tests/unit/test_progressbar.py new file mode 100644 index 0000000..1dd42a0 --- /dev/null +++ b/glanceclient/tests/unit/test_progressbar.py @@ -0,0 +1,75 @@ +# Copyright 2013 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 sys + +import six +import testtools + +from glanceclient.common import progressbar +from glanceclient.tests import utils as test_utils + + +class TestProgressBarWrapper(testtools.TestCase): + + def test_iter_iterator_display_progress_bar(self): + size = 100 + iterator = iter('X' * 100) + saved_stdout = sys.stdout + try: + sys.stdout = output = test_utils.FakeTTYStdout() + # Consume iterator. + data = list(progressbar.VerboseIteratorWrapper(iterator, size)) + self.assertEqual(['X'] * 100, data) + self.assertEqual( + '[%s>] 100%%\n' % ('=' * 29), + output.getvalue() + ) + finally: + sys.stdout = saved_stdout + + def test_iter_file_display_progress_bar(self): + size = 98304 + file_obj = six.StringIO('X' * size) + saved_stdout = sys.stdout + try: + sys.stdout = output = test_utils.FakeTTYStdout() + file_obj = progressbar.VerboseFileWrapper(file_obj, size) + chunksize = 1024 + chunk = file_obj.read(chunksize) + while chunk: + chunk = file_obj.read(chunksize) + self.assertEqual( + '[%s>] 100%%\n' % ('=' * 29), + output.getvalue() + ) + finally: + sys.stdout = saved_stdout + + def test_iter_file_no_tty(self): + size = 98304 + file_obj = six.StringIO('X' * size) + saved_stdout = sys.stdout + try: + sys.stdout = output = test_utils.FakeNoTTYStdout() + file_obj = progressbar.VerboseFileWrapper(file_obj, size) + chunksize = 1024 + chunk = file_obj.read(chunksize) + while chunk: + chunk = file_obj.read(chunksize) + # If stdout is not a tty progress bar should do nothing. + self.assertEqual('', output.getvalue()) + finally: + sys.stdout = saved_stdout diff --git a/glanceclient/tests/unit/test_shell.py b/glanceclient/tests/unit/test_shell.py new file mode 100644 index 0000000..ef15561 --- /dev/null +++ b/glanceclient/tests/unit/test_shell.py @@ -0,0 +1,512 @@ +# Copyright 2013 OpenStack Foundation +# Copyright (C) 2013 Yahoo! 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 argparse +import os +import sys +import uuid + +import fixtures +from keystoneclient import exceptions as ks_exc +from keystoneclient import fixture as ks_fixture +import mock +import requests +from requests_mock.contrib import fixture as rm_fixture +import six + +from glanceclient import exc +from glanceclient import shell as openstack_shell + +from glanceclient.tests import utils +#NOTE (esheffield) Used for the schema caching tests +from glanceclient.v2 import schemas as schemas +import json + + +DEFAULT_IMAGE_URL = 'http://127.0.0.1:5000/' +DEFAULT_USERNAME = 'username' +DEFAULT_PASSWORD = 'password' +DEFAULT_TENANT_ID = 'tenant_id' +DEFAULT_TENANT_NAME = 'tenant_name' +DEFAULT_PROJECT_ID = '0123456789' +DEFAULT_USER_DOMAIN_NAME = 'user_domain_name' +DEFAULT_UNVERSIONED_AUTH_URL = 'http://127.0.0.1:5000/' +DEFAULT_V2_AUTH_URL = '%sv2.0' % DEFAULT_UNVERSIONED_AUTH_URL +DEFAULT_V3_AUTH_URL = '%sv3' % DEFAULT_UNVERSIONED_AUTH_URL +DEFAULT_AUTH_TOKEN = ' 3bcc3d3a03f44e3d8377f9247b0ad155' +TEST_SERVICE_URL = 'http://127.0.0.1:5000/' + +FAKE_V2_ENV = {'OS_USERNAME': DEFAULT_USERNAME, + 'OS_PASSWORD': DEFAULT_PASSWORD, + 'OS_TENANT_NAME': DEFAULT_TENANT_NAME, + 'OS_AUTH_URL': DEFAULT_V2_AUTH_URL, + 'OS_IMAGE_URL': DEFAULT_IMAGE_URL} + +FAKE_V3_ENV = {'OS_USERNAME': DEFAULT_USERNAME, + 'OS_PASSWORD': DEFAULT_PASSWORD, + 'OS_PROJECT_ID': DEFAULT_PROJECT_ID, + 'OS_USER_DOMAIN_NAME': DEFAULT_USER_DOMAIN_NAME, + 'OS_AUTH_URL': DEFAULT_V3_AUTH_URL, + 'OS_IMAGE_URL': DEFAULT_IMAGE_URL} + +TOKEN_ID = uuid.uuid4().hex + +V2_TOKEN = ks_fixture.V2Token(token_id=TOKEN_ID) +V2_TOKEN.set_scope() +_s = V2_TOKEN.add_service('image', name='glance') +_s.add_endpoint(DEFAULT_IMAGE_URL) + +V3_TOKEN = ks_fixture.V3Token() +V3_TOKEN.set_project_scope() +_s = V3_TOKEN.add_service('image', name='glance') +_s.add_standard_endpoints(public=DEFAULT_IMAGE_URL) + + +class ShellTest(utils.TestCase): + # auth environment to use + auth_env = FAKE_V2_ENV.copy() + # expected auth plugin to invoke + token_url = DEFAULT_V2_AUTH_URL + '/tokens' + + # Patch os.environ to avoid required auth info + def make_env(self, exclude=None): + env = dict((k, v) for k, v in self.auth_env.items() if k != exclude) + self.useFixture(fixtures.MonkeyPatch('os.environ', env)) + + def setUp(self): + super(ShellTest, self).setUp() + global _old_env + _old_env, os.environ = os.environ, self.auth_env + + self.requests = self.useFixture(rm_fixture.Fixture()) + + json_list = ks_fixture.DiscoveryList(DEFAULT_UNVERSIONED_AUTH_URL) + self.requests.get(DEFAULT_IMAGE_URL, json=json_list, status_code=300) + + json_v2 = {'version': ks_fixture.V2Discovery(DEFAULT_V2_AUTH_URL)} + self.requests.get(DEFAULT_V2_AUTH_URL, json=json_v2) + + json_v3 = {'version': ks_fixture.V3Discovery(DEFAULT_V3_AUTH_URL)} + self.requests.get(DEFAULT_V3_AUTH_URL, json=json_v3) + + self.v2_auth = self.requests.post(DEFAULT_V2_AUTH_URL + '/tokens', + json=V2_TOKEN) + + headers = {'X-Subject-Token': TOKEN_ID} + self.v3_auth = self.requests.post(DEFAULT_V3_AUTH_URL + '/auth/tokens', + headers=headers, + json=V3_TOKEN) + + global shell, _shell, assert_called, assert_called_anytime + _shell = openstack_shell.OpenStackImagesShell() + shell = lambda cmd: _shell.main(cmd.split()) + + def tearDown(self): + super(ShellTest, self).tearDown() + global _old_env + os.environ = _old_env + + def shell(self, argstr, exitcodes=(0,)): + orig = sys.stdout + orig_stderr = sys.stderr + try: + sys.stdout = six.StringIO() + sys.stderr = six.StringIO() + _shell = openstack_shell.OpenStackImagesShell() + _shell.main(argstr.split()) + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertIn(exc_value.code, exitcodes) + finally: + stdout = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + stderr = sys.stderr.getvalue() + sys.stderr.close() + sys.stderr = orig_stderr + return (stdout, stderr) + + def test_help_unknown_command(self): + shell = openstack_shell.OpenStackImagesShell() + argstr = 'help foofoo' + self.assertRaises(exc.CommandError, shell.main, argstr.split()) + + def test_help(self): + shell = openstack_shell.OpenStackImagesShell() + argstr = 'help' + actual = shell.main(argstr.split()) + self.assertEqual(0, actual) + + def test_help_on_subcommand_error(self): + self.assertRaises(exc.CommandError, shell, 'help bad') + + def test_get_base_parser(self): + test_shell = openstack_shell.OpenStackImagesShell() + actual_parser = test_shell.get_base_parser() + description = 'Command-line interface to the OpenStack Images API.' + expected = argparse.ArgumentParser( + prog='glance', usage=None, + description=description, + conflict_handler='error', + add_help=False, + formatter_class=openstack_shell.HelpFormatter,) + # NOTE(guochbo): Can't compare ArgumentParser instances directly + # Convert ArgumentPaser to string first. + self.assertEqual(str(expected), str(actual_parser)) + + @mock.patch.object(openstack_shell.OpenStackImagesShell, + '_get_versioned_client') + def test_cert_and_key_args_interchangeable(self, + mock_versioned_client): + # make sure --os-cert and --os-key are passed correctly + args = '--os-cert mycert --os-key mykey image-list' + shell(args) + assert mock_versioned_client.called + ((api_version, args), kwargs) = mock_versioned_client.call_args + self.assertEqual('mycert', args.os_cert) + self.assertEqual('mykey', args.os_key) + + # make sure we get the same thing with --cert-file and --key-file + args = '--cert-file mycertfile --key-file mykeyfile image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + assert mock_versioned_client.called + ((api_version, args), kwargs) = mock_versioned_client.call_args + self.assertEqual('mycertfile', args.os_cert) + self.assertEqual('mykeyfile', args.os_key) + + @mock.patch('glanceclient.v1.client.Client') + def test_no_auth_with_token_and_image_url_with_v1(self, v1_client): + # test no authentication is required if both token and endpoint url + # are specified + args = ('--os-auth-token mytoken --os-image-url https://image:1234/v1 ' + 'image-list') + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + assert v1_client.called + (args, kwargs) = v1_client.call_args + self.assertEqual('mytoken', kwargs['token']) + self.assertEqual('https://image:1234', args[0]) + + @mock.patch.object(openstack_shell.OpenStackImagesShell, '_cache_schemas') + def test_no_auth_with_token_and_image_url_with_v2(self, + cache_schemas): + with mock.patch('glanceclient.v2.client.Client') as v2_client: + # test no authentication is required if both token and endpoint url + # are specified + args = ('--os-auth-token mytoken ' + '--os-image-url https://image:1234/v2 ' + '--os-image-api-version 2 image-list') + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + ((args), kwargs) = v2_client.call_args + self.assertEqual('https://image:1234', args[0]) + self.assertEqual('mytoken', kwargs['token']) + + def _assert_auth_plugin_args(self): + # make sure our auth plugin is invoked with the correct args + self.assertEqual(1, self.v2_auth.call_count) + self.assertFalse(self.v3_auth.called) + + body = json.loads(self.v2_auth.last_request.body) + + self.assertEqual(self.auth_env['OS_TENANT_NAME'], + body['auth']['tenantName']) + self.assertEqual(self.auth_env['OS_USERNAME'], + body['auth']['passwordCredentials']['username']) + self.assertEqual(self.auth_env['OS_PASSWORD'], + body['auth']['passwordCredentials']['password']) + + @mock.patch('glanceclient.v1.client.Client') + def test_auth_plugin_invocation_with_v1(self, v1_client): + args = 'image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self._assert_auth_plugin_args() + + @mock.patch('glanceclient.v2.client.Client') + @mock.patch.object(openstack_shell.OpenStackImagesShell, '_cache_schemas') + def test_auth_plugin_invocation_with_v2(self, + v2_client, + cache_schemas): + args = '--os-image-api-version 2 image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self._assert_auth_plugin_args() + + @mock.patch('glanceclient.v1.client.Client') + def test_auth_plugin_invocation_with_unversioned_auth_url_with_v1( + self, v1_client): + args = '--os-auth-url %s image-list' % DEFAULT_UNVERSIONED_AUTH_URL + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self._assert_auth_plugin_args() + + @mock.patch('glanceclient.v2.client.Client') + @mock.patch.object(openstack_shell.OpenStackImagesShell, '_cache_schemas') + def test_auth_plugin_invocation_with_unversioned_auth_url_with_v2( + self, v2_client, cache_schemas): + args = ('--os-auth-url %s --os-image-api-version 2 ' + 'image-list') % DEFAULT_UNVERSIONED_AUTH_URL + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self._assert_auth_plugin_args() + + @mock.patch('sys.stdin', side_effect=mock.MagicMock) + @mock.patch('getpass.getpass', return_value='password') + def test_password_prompted_with_v2(self, mock_getpass, mock_stdin): + self.requests.post(self.token_url, exc=requests.ConnectionError) + glance_shell = openstack_shell.OpenStackImagesShell() + self.make_env(exclude='OS_PASSWORD') + self.assertRaises(ks_exc.ConnectionRefused, + glance_shell.main, ['image-list']) + # Make sure we are actually prompted. + mock_getpass.assert_called_with('OS Password: ') + + @mock.patch('sys.stdin', side_effect=mock.MagicMock) + @mock.patch('getpass.getpass', side_effect=EOFError) + def test_password_prompted_ctrlD_with_v2(self, mock_getpass, mock_stdin): + glance_shell = openstack_shell.OpenStackImagesShell() + self.make_env(exclude='OS_PASSWORD') + # We should get Command Error because we mock Ctl-D. + self.assertRaises(exc.CommandError, glance_shell.main, ['image-list']) + # Make sure we are actually prompted. + mock_getpass.assert_called_with('OS Password: ') + + @mock.patch( + 'glanceclient.shell.OpenStackImagesShell._get_keystone_session') + @mock.patch.object(openstack_shell.OpenStackImagesShell, '_cache_schemas') + def test_no_auth_with_proj_name(self, cache_schemas, session): + with mock.patch('glanceclient.v2.client.Client'): + args = ('--os-project-name myname ' + '--os-project-domain-name mydomain ' + '--os-project-domain-id myid ' + '--os-image-api-version 2 image-list') + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + ((args), kwargs) = session.call_args + self.assertEqual('myname', kwargs['project_name']) + self.assertEqual('mydomain', kwargs['project_domain_name']) + self.assertEqual('myid', kwargs['project_domain_id']) + + @mock.patch.object(openstack_shell.OpenStackImagesShell, 'main') + def test_shell_keyboard_interrupt(self, mock_glance_shell): + # Ensure that exit code is 130 for KeyboardInterrupt + try: + mock_glance_shell.side_effect = KeyboardInterrupt() + openstack_shell.main() + except SystemExit as ex: + self.assertEqual(130, ex.code) + + @mock.patch('glanceclient.v1.client.Client') + def test_auth_plugin_invocation_without_username_with_v1(self, v1_client): + self.make_env(exclude='OS_USERNAME') + args = 'image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + self.assertRaises(exc.CommandError, glance_shell.main, args.split()) + + @mock.patch('glanceclient.v2.client.Client') + def test_auth_plugin_invocation_without_username_with_v2(self, v2_client): + self.make_env(exclude='OS_USERNAME') + args = '--os-image-api-version 2 image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + self.assertRaises(exc.CommandError, glance_shell.main, args.split()) + + @mock.patch('glanceclient.v1.client.Client') + def test_auth_plugin_invocation_without_auth_url_with_v1(self, v1_client): + self.make_env(exclude='OS_AUTH_URL') + args = 'image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + self.assertRaises(exc.CommandError, glance_shell.main, args.split()) + + @mock.patch('glanceclient.v2.client.Client') + def test_auth_plugin_invocation_without_auth_url_with_v2(self, v2_client): + self.make_env(exclude='OS_AUTH_URL') + args = '--os-image-api-version 2 image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + self.assertRaises(exc.CommandError, glance_shell.main, args.split()) + + @mock.patch('glanceclient.v1.client.Client') + def test_auth_plugin_invocation_without_tenant_with_v1(self, v1_client): + if 'OS_TENANT_NAME' in os.environ: + self.make_env(exclude='OS_TENANT_NAME') + if 'OS_PROJECT_ID' in os.environ: + self.make_env(exclude='OS_PROJECT_ID') + args = 'image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + self.assertRaises(exc.CommandError, glance_shell.main, args.split()) + + @mock.patch('glanceclient.v2.client.Client') + def test_auth_plugin_invocation_without_tenant_with_v2(self, v2_client): + if 'OS_TENANT_NAME' in os.environ: + self.make_env(exclude='OS_TENANT_NAME') + if 'OS_PROJECT_ID' in os.environ: + self.make_env(exclude='OS_PROJECT_ID') + args = '--os-image-api-version 2 image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + self.assertRaises(exc.CommandError, glance_shell.main, args.split()) + + +class ShellTestWithKeystoneV3Auth(ShellTest): + # auth environment to use + auth_env = FAKE_V3_ENV.copy() + token_url = DEFAULT_V3_AUTH_URL + '/auth/tokens' + + def _assert_auth_plugin_args(self): + self.assertFalse(self.v2_auth.called) + self.assertEqual(1, self.v3_auth.call_count) + + body = json.loads(self.v3_auth.last_request.body) + user = body['auth']['identity']['password']['user'] + + self.assertEqual(self.auth_env['OS_USERNAME'], user['name']) + self.assertEqual(self.auth_env['OS_PASSWORD'], user['password']) + self.assertEqual(self.auth_env['OS_USER_DOMAIN_NAME'], + user['domain']['name']) + self.assertEqual(self.auth_env['OS_PROJECT_ID'], + body['auth']['scope']['project']['id']) + + @mock.patch('glanceclient.v1.client.Client') + def test_auth_plugin_invocation_with_v1(self, v1_client): + args = 'image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self._assert_auth_plugin_args() + + @mock.patch('glanceclient.v2.client.Client') + @mock.patch.object(openstack_shell.OpenStackImagesShell, '_cache_schemas') + def test_auth_plugin_invocation_with_v2(self, v2_client, cache_schemas): + args = '--os-image-api-version 2 image-list' + glance_shell = openstack_shell.OpenStackImagesShell() + glance_shell.main(args.split()) + self._assert_auth_plugin_args() + + @mock.patch('keystoneclient.discover.Discover', + side_effect=ks_exc.ClientException()) + def test_api_discovery_failed_with_unversioned_auth_url(self, + discover): + args = '--os-auth-url %s image-list' % DEFAULT_UNVERSIONED_AUTH_URL + glance_shell = openstack_shell.OpenStackImagesShell() + self.assertRaises(exc.CommandError, glance_shell.main, args.split()) + + def test_bash_completion(self): + stdout, stderr = self.shell('bash_completion') + # just check we have some output + required = [ + '--status', + 'image-create', + 'help', + '--size'] + for r in required: + self.assertIn(r, stdout.split()) + avoided = [ + 'bash_completion', + 'bash-completion'] + for r in avoided: + self.assertNotIn(r, stdout.split()) + + +class ShellCacheSchemaTest(utils.TestCase): + def setUp(self): + super(ShellCacheSchemaTest, self).setUp() + self._mock_client_setup() + self._mock_shell_setup() + self.cache_dir = '/dir_for_cached_schema' + self.cache_files = [self.cache_dir + '/image_schema.json', + self.cache_dir + '/namespace_schema.json', + self.cache_dir + '/resource_type_schema.json'] + + def tearDown(self): + super(ShellCacheSchemaTest, self).tearDown() + + def _mock_client_setup(self): + self.schema_dict = { + 'name': 'image', + 'properties': { + 'name': {'type': 'string', 'description': 'Name of image'}, + }, + } + + self.client = mock.Mock() + self.client.schemas.get.return_value = schemas.Schema(self.schema_dict) + + def _mock_shell_setup(self): + mocked_get_client = mock.MagicMock(return_value=self.client) + self.shell = openstack_shell.OpenStackImagesShell() + self.shell._get_versioned_client = mocked_get_client + + def _make_args(self, args): + class Args(): + def __init__(self, entries): + self.__dict__.update(entries) + + return Args(args) + + @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) + @mock.patch('os.path.exists', return_value=True) + def test_cache_schemas_gets_when_forced(self, exists_mock): + options = { + 'get_schema': True + } + + self.shell._cache_schemas(self._make_args(options), + home_dir=self.cache_dir) + + self.assertEqual(12, open.mock_calls.__len__()) + self.assertEqual(mock.call(self.cache_files[0], 'w'), + open.mock_calls[0]) + self.assertEqual(mock.call(self.cache_files[1], 'w'), + open.mock_calls[4]) + self.assertEqual(mock.call().write(json.dumps(self.schema_dict)), + open.mock_calls[2]) + self.assertEqual(mock.call().write(json.dumps(self.schema_dict)), + open.mock_calls[6]) + + @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) + @mock.patch('os.path.exists', side_effect=[True, False, False, False]) + def test_cache_schemas_gets_when_not_exists(self, exists_mock): + options = { + 'get_schema': False + } + + self.shell._cache_schemas(self._make_args(options), + home_dir=self.cache_dir) + + self.assertEqual(12, open.mock_calls.__len__()) + self.assertEqual(mock.call(self.cache_files[0], 'w'), + open.mock_calls[0]) + self.assertEqual(mock.call(self.cache_files[1], 'w'), + open.mock_calls[4]) + self.assertEqual(mock.call().write(json.dumps(self.schema_dict)), + open.mock_calls[2]) + self.assertEqual(mock.call().write(json.dumps(self.schema_dict)), + open.mock_calls[6]) + + @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) + @mock.patch('os.path.exists', return_value=True) + def test_cache_schemas_leaves_when_present_not_forced(self, exists_mock): + options = { + 'get_schema': False + } + + self.shell._cache_schemas(self._make_args(options), + home_dir=self.cache_dir) + + os.path.exists.assert_any_call(self.cache_dir) + os.path.exists.assert_any_call(self.cache_files[0]) + os.path.exists.assert_any_call(self.cache_files[1]) + self.assertEqual(4, exists_mock.call_count) + self.assertEqual(0, open.mock_calls.__len__()) diff --git a/glanceclient/tests/unit/test_ssl.py b/glanceclient/tests/unit/test_ssl.py new file mode 100644 index 0000000..907b7bf --- /dev/null +++ b/glanceclient/tests/unit/test_ssl.py @@ -0,0 +1,452 @@ +# 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 os + +from OpenSSL import crypto +from OpenSSL import SSL +try: + from requests.packages.urllib3 import poolmanager +except ImportError: + from urllib3 import poolmanager +import six +import ssl +import testtools +import threading + +from glanceclient.common import http +from glanceclient.common import https + +from glanceclient import Client +from glanceclient import exc + +if six.PY3 is True: + import socketserver +else: + import SocketServer as socketserver + + +TEST_VAR_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), + 'var')) + + +class ThreadedTCPRequestHandler(socketserver.BaseRequestHandler): + def handle(self): + self.request.recv(1024) + response = b'somebytes' + self.request.sendall(response) + + +class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + def get_request(self): + key_file = os.path.join(TEST_VAR_DIR, 'privatekey.key') + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + (_sock, addr) = socketserver.TCPServer.get_request(self) + sock = ssl.wrap_socket(_sock, + certfile=cert_file, + keyfile=key_file, + ca_certs=cacert, + server_side=True, + cert_reqs=ssl.CERT_REQUIRED) + return sock, addr + + +class TestHTTPSVerifyCert(testtools.TestCase): + """Check 'requests' based ssl verification occurs + + The requests library performs SSL certificate validation, + however there is still a need to check that the glance + client is properly integrated with requests so that + cert validation actually happens. + """ + def setUp(self): + # Rather than spinning up a new process, we create + # a thread to perform client/server interaction. + # This should run more quickly. + super(TestHTTPSVerifyCert, self).setUp() + server = ThreadedTCPServer(('127.0.0.1', 0), + ThreadedTCPRequestHandler) + __, self.port = server.server_address + server_thread = threading.Thread(target=server.serve_forever) + server_thread.daemon = True + server_thread.start() + + def test_v1_requests_cert_verification(self): + """v1 regression test for bug 115260.""" + port = self.port + url = 'https://0.0.0.0:%d' % port + + try: + client = Client('1', url, + insecure=False, + ssl_compression=True) + client.images.get('image123') + self.fail('No SSL exception raised') + except exc.CommunicationError as e: + if 'certificate verify failed' not in e.message: + self.fail('No certificate failure message received') + except Exception as e: + self.fail('Unexpected exception raised') + + def test_v1_requests_cert_verification_no_compression(self): + """v1 regression test for bug 115260.""" + port = self.port + url = 'https://0.0.0.0:%d' % port + + try: + client = Client('1', url, + insecure=False, + ssl_compression=False) + client.images.get('image123') + self.fail('No SSL exception raised') + except SSL.Error as e: + if 'certificate verify failed' not in str(e): + self.fail('No certificate failure message received') + except Exception as e: + self.fail('Unexpected exception raised') + + def test_v2_requests_cert_verification(self): + """v2 regression test for bug 115260.""" + port = self.port + url = 'https://0.0.0.0:%d' % port + + try: + gc = Client('2', url, + insecure=False, + ssl_compression=True) + gc.images.get('image123') + self.fail('No SSL exception raised') + except exc.CommunicationError as e: + if 'certificate verify failed' not in e.message: + self.fail('No certificate failure message received') + except Exception as e: + self.fail('Unexpected exception raised') + + def test_v2_requests_cert_verification_no_compression(self): + """v2 regression test for bug 115260.""" + port = self.port + url = 'https://0.0.0.0:%d' % port + + try: + gc = Client('2', url, + insecure=False, + ssl_compression=False) + gc.images.get('image123') + self.fail('No SSL exception raised') + except SSL.Error as e: + if 'certificate verify failed' not in str(e): + self.fail('No certificate failure message received') + except Exception as e: + self.fail('Unexpected exception raised') + + +class TestVerifiedHTTPSConnection(testtools.TestCase): + def test_ssl_init_ok(self): + """ + Test VerifiedHTTPSConnection class init + """ + key_file = os.path.join(TEST_VAR_DIR, 'privatekey.key') + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + try: + https.VerifiedHTTPSConnection('127.0.0.1', 0, + key_file=key_file, + cert_file=cert_file, + cacert=cacert) + except exc.SSLConfigurationError: + self.fail('Failed to init VerifiedHTTPSConnection.') + + def test_ssl_init_cert_no_key(self): + """ + Test VerifiedHTTPSConnection: absence of SSL key file. + """ + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + try: + https.VerifiedHTTPSConnection('127.0.0.1', 0, + cert_file=cert_file, + cacert=cacert) + self.fail('Failed to raise assertion.') + except exc.SSLConfigurationError: + pass + + def test_ssl_init_key_no_cert(self): + """ + Test VerifiedHTTPSConnection: absence of SSL cert file. + """ + key_file = os.path.join(TEST_VAR_DIR, 'privatekey.key') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + try: + https.VerifiedHTTPSConnection('127.0.0.1', 0, + key_file=key_file, + cacert=cacert) + except exc.SSLConfigurationError: + pass + except Exception: + self.fail('Failed to init VerifiedHTTPSConnection.') + + def test_ssl_init_bad_key(self): + """ + Test VerifiedHTTPSConnection: bad key. + """ + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + key_file = os.path.join(TEST_VAR_DIR, 'badkey.key') + try: + https.VerifiedHTTPSConnection('127.0.0.1', 0, + key_file=key_file, + cert_file=cert_file, + cacert=cacert) + self.fail('Failed to raise assertion.') + except exc.SSLConfigurationError: + pass + + def test_ssl_init_bad_cert(self): + """ + Test VerifiedHTTPSConnection: bad cert. + """ + cert_file = os.path.join(TEST_VAR_DIR, 'badcert.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + try: + https.VerifiedHTTPSConnection('127.0.0.1', 0, + cert_file=cert_file, + cacert=cacert) + self.fail('Failed to raise assertion.') + except exc.SSLConfigurationError: + pass + + def test_ssl_init_bad_ca(self): + """ + Test VerifiedHTTPSConnection: bad CA. + """ + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'badca.crt') + try: + https.VerifiedHTTPSConnection('127.0.0.1', 0, + cert_file=cert_file, + cacert=cacert) + self.fail('Failed to raise assertion.') + except exc.SSLConfigurationError: + pass + + def test_ssl_cert_cname(self): + """ + Test certificate: CN match + """ + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cert = crypto.load_certificate(crypto.FILETYPE_PEM, + open(cert_file).read()) + # The expected cert should have CN=0.0.0.0 + self.assertEqual('0.0.0.0', cert.get_subject().commonName) + try: + conn = https.VerifiedHTTPSConnection('0.0.0.0', 0) + https.do_verify_callback(None, cert, 0, 0, 1, host=conn.host) + except Exception: + self.fail('Unexpected exception.') + + def test_ssl_cert_cname_wildcard(self): + """ + Test certificate: wildcard CN match + """ + cert_file = os.path.join(TEST_VAR_DIR, 'wildcard-certificate.crt') + cert = crypto.load_certificate(crypto.FILETYPE_PEM, + open(cert_file).read()) + # The expected cert should have CN=*.pong.example.com + self.assertEqual('*.pong.example.com', cert.get_subject().commonName) + try: + conn = https.VerifiedHTTPSConnection('ping.pong.example.com', 0) + https.do_verify_callback(None, cert, 0, 0, 1, host=conn.host) + except Exception: + self.fail('Unexpected exception.') + + def test_ssl_cert_subject_alt_name(self): + """ + Test certificate: SAN match + """ + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cert = crypto.load_certificate(crypto.FILETYPE_PEM, + open(cert_file).read()) + # The expected cert should have CN=0.0.0.0 + self.assertEqual('0.0.0.0', cert.get_subject().commonName) + try: + conn = https.VerifiedHTTPSConnection('alt1.example.com', 0) + https.do_verify_callback(None, cert, 0, 0, 1, host=conn.host) + except Exception: + self.fail('Unexpected exception.') + + try: + conn = https.VerifiedHTTPSConnection('alt2.example.com', 0) + https.do_verify_callback(None, cert, 0, 0, 1, host=conn.host) + except Exception: + self.fail('Unexpected exception.') + + def test_ssl_cert_subject_alt_name_wildcard(self): + """ + Test certificate: wildcard SAN match + """ + cert_file = os.path.join(TEST_VAR_DIR, 'wildcard-san-certificate.crt') + cert = crypto.load_certificate(crypto.FILETYPE_PEM, + open(cert_file).read()) + # The expected cert should have CN=0.0.0.0 + self.assertEqual('0.0.0.0', cert.get_subject().commonName) + try: + conn = https.VerifiedHTTPSConnection('alt1.example.com', 0) + https.do_verify_callback(None, cert, 0, 0, 1, host=conn.host) + except Exception: + self.fail('Unexpected exception.') + + try: + conn = https.VerifiedHTTPSConnection('alt2.example.com', 0) + https.do_verify_callback(None, cert, 0, 0, 1, host=conn.host) + except Exception: + self.fail('Unexpected exception.') + + try: + conn = https.VerifiedHTTPSConnection('alt3.example.net', 0) + https.do_verify_callback(None, cert, 0, 0, 1, host=conn.host) + self.fail('Failed to raise assertion.') + except exc.SSLCertificateError: + pass + + def test_ssl_cert_mismatch(self): + """ + Test certificate: bogus host + """ + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cert = crypto.load_certificate(crypto.FILETYPE_PEM, + open(cert_file).read()) + # The expected cert should have CN=0.0.0.0 + self.assertEqual('0.0.0.0', cert.get_subject().commonName) + try: + conn = https.VerifiedHTTPSConnection('mismatch.example.com', 0) + except Exception: + self.fail('Failed to init VerifiedHTTPSConnection.') + + self.assertRaises(exc.SSLCertificateError, + https.do_verify_callback, None, cert, 0, 0, 1, + host=conn.host) + + def test_ssl_expired_cert(self): + """ + Test certificate: out of date cert + """ + cert_file = os.path.join(TEST_VAR_DIR, 'expired-cert.crt') + cert = crypto.load_certificate(crypto.FILETYPE_PEM, + open(cert_file).read()) + # The expected expired cert has CN=openstack.example.com + self.assertEqual('openstack.example.com', + cert.get_subject().commonName) + try: + conn = https.VerifiedHTTPSConnection('openstack.example.com', 0) + except Exception: + raise + self.fail('Failed to init VerifiedHTTPSConnection.') + self.assertRaises(exc.SSLCertificateError, + https.do_verify_callback, None, cert, 0, 0, 1, + host=conn.host) + + def test_ssl_broken_key_file(self): + """ + Test verify exception is raised. + """ + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + key_file = 'fake.key' + self.assertRaises( + exc.SSLConfigurationError, + https.VerifiedHTTPSConnection, '127.0.0.1', + 0, key_file=key_file, + cert_file=cert_file, cacert=cacert) + + def test_ssl_init_ok_with_insecure_true(self): + """ + Test VerifiedHTTPSConnection class init + """ + key_file = os.path.join(TEST_VAR_DIR, 'privatekey.key') + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + try: + https.VerifiedHTTPSConnection( + '127.0.0.1', 0, + key_file=key_file, + cert_file=cert_file, + cacert=cacert, insecure=True) + except exc.SSLConfigurationError: + self.fail('Failed to init VerifiedHTTPSConnection.') + + def test_ssl_init_ok_with_ssl_compression_false(self): + """ + Test VerifiedHTTPSConnection class init + """ + key_file = os.path.join(TEST_VAR_DIR, 'privatekey.key') + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + try: + https.VerifiedHTTPSConnection( + '127.0.0.1', 0, + key_file=key_file, + cert_file=cert_file, + cacert=cacert, ssl_compression=False) + except exc.SSLConfigurationError: + self.fail('Failed to init VerifiedHTTPSConnection.') + + def test_ssl_init_non_byte_string(self): + """ + Test VerifiedHTTPSConnection class non byte string + + Reproduces bug #1301849 + """ + key_file = os.path.join(TEST_VAR_DIR, 'privatekey.key') + cert_file = os.path.join(TEST_VAR_DIR, 'certificate.crt') + cacert = os.path.join(TEST_VAR_DIR, 'ca.crt') + # Note: we reproduce on python 2.6/2.7, on 3.3 the bug doesn't occur. + key_file = key_file.encode('ascii', 'strict').decode('utf-8') + cert_file = cert_file.encode('ascii', 'strict').decode('utf-8') + cacert = cacert.encode('ascii', 'strict').decode('utf-8') + try: + https.VerifiedHTTPSConnection('127.0.0.1', 0, + key_file=key_file, + cert_file=cert_file, + cacert=cacert) + except exc.SSLConfigurationError: + self.fail('Failed to init VerifiedHTTPSConnection.') + + +class TestRequestsIntegration(testtools.TestCase): + + def test_pool_patch(self): + client = http.HTTPClient("https://localhost", + ssl_compression=True) + self.assertNotEqual(https.HTTPSConnectionPool, + poolmanager.pool_classes_by_scheme["https"]) + + adapter = client.session.adapters.get("https://") + self.assertFalse(isinstance(adapter, https.HTTPSAdapter)) + + adapter = client.session.adapters.get("glance+https://") + self.assertFalse(isinstance(adapter, https.HTTPSAdapter)) + + def test_custom_https_adapter(self): + client = http.HTTPClient("https://localhost", + ssl_compression=False) + self.assertNotEqual(https.HTTPSConnectionPool, + poolmanager.pool_classes_by_scheme["https"]) + + adapter = client.session.adapters.get("https://") + self.assertFalse(isinstance(adapter, https.HTTPSAdapter)) + + adapter = client.session.adapters.get("glance+https://") + self.assertTrue(isinstance(adapter, https.HTTPSAdapter)) diff --git a/glanceclient/tests/unit/test_utils.py b/glanceclient/tests/unit/test_utils.py new file mode 100644 index 0000000..564d7f6 --- /dev/null +++ b/glanceclient/tests/unit/test_utils.py @@ -0,0 +1,165 @@ +# 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 sys + +import six +# NOTE(jokke): simplified transition to py3, behaves like py2 xrange +from six.moves import range +import testtools + +from glanceclient.common import utils + + +class TestUtils(testtools.TestCase): + + def test_make_size_human_readable(self): + self.assertEqual("106B", utils.make_size_human_readable(106)) + self.assertEqual("1000kB", utils.make_size_human_readable(1024000)) + self.assertEqual("1MB", utils.make_size_human_readable(1048576)) + self.assertEqual("1.4GB", utils.make_size_human_readable(1476395008)) + self.assertEqual("9.3MB", utils.make_size_human_readable(9761280)) + + def test_get_new_file_size(self): + size = 98304 + file_obj = six.StringIO('X' * size) + try: + self.assertEqual(size, utils.get_file_size(file_obj)) + # Check that get_file_size didn't change original file position. + self.assertEqual(0, file_obj.tell()) + finally: + file_obj.close() + + def test_get_consumed_file_size(self): + size, consumed = 98304, 304 + file_obj = six.StringIO('X' * size) + file_obj.seek(consumed) + try: + self.assertEqual(size, utils.get_file_size(file_obj)) + # Check that get_file_size didn't change original file position. + self.assertEqual(consumed, file_obj.tell()) + finally: + file_obj.close() + + def test_prettytable(self): + class Struct: + def __init__(self, **entries): + self.__dict__.update(entries) + + # test that the prettytable output is wellformatted (left-aligned) + columns = ['ID', 'Name'] + val = ['Name1', 'another', 'veeeery long'] + images = [Struct(**{'id': i ** 16, 'name': val[i]}) + for i in range(len(val))] + + saved_stdout = sys.stdout + try: + sys.stdout = output_list = six.StringIO() + utils.print_list(images, columns) + + sys.stdout = output_dict = six.StringIO() + utils.print_dict({'K': 'k', 'Key': 'veeeeeeeeeeeeeeeeeeeeeeee' + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' + 'eeeeeeeeeeeery long value'}, + max_column_width=60) + + finally: + sys.stdout = saved_stdout + + self.assertEqual('''\ ++-------+--------------+ +| ID | Name | ++-------+--------------+ +| | Name1 | +| 1 | another | +| 65536 | veeeery long | ++-------+--------------+ +''', + output_list.getvalue()) + + self.assertEqual('''\ ++----------+--------------------------------------------------------------+ +| Property | Value | ++----------+--------------------------------------------------------------+ +| K | k | +| Key | veeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee | +| | eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee | +| | ery long value | ++----------+--------------------------------------------------------------+ +''', + output_dict.getvalue()) + + def test_exception_to_str(self): + class FakeException(Exception): + def __str__(self): + raise UnicodeError() + + ret = utils.exception_to_str(Exception('error message')) + self.assertEqual('error message', ret) + + ret = utils.exception_to_str(Exception('\xa5 error message')) + if six.PY2: + self.assertEqual(' error message', ret) + else: + self.assertEqual('\xa5 error message', ret) + + ret = utils.exception_to_str(FakeException('\xa5 error message')) + self.assertEqual("Caught '%(exception)s' exception." % + {'exception': 'FakeException'}, ret) + + def test_schema_args_with_list_types(self): + # NOTE(flaper87): Regression for bug + # https://bugs.launchpad.net/python-glanceclient/+bug/1401032 + + def schema_getter(_type='string', enum=False): + prop = { + 'type': ['null', _type], + 'description': 'Test schema (READ-ONLY)', + } + + if enum: + prop['enum'] = [None, 'opt-1', 'opt-2'] + + def actual_getter(): + return { + 'additionalProperties': False, + 'required': ['name'], + 'name': 'test_schema', + 'properties': { + 'test': prop, + } + } + + return actual_getter + + def dummy_func(): + pass + + decorated = utils.schema_args(schema_getter())(dummy_func) + arg, opts = decorated.__dict__['arguments'][0] + self.assertIn('--test', arg) + self.assertEqual(str, opts['type']) + + decorated = utils.schema_args(schema_getter('integer'))(dummy_func) + arg, opts = decorated.__dict__['arguments'][0] + self.assertIn('--test', arg) + self.assertEqual(int, opts['type']) + + decorated = utils.schema_args(schema_getter(enum=True))(dummy_func) + arg, opts = decorated.__dict__['arguments'][0] + self.assertIn('--test', arg) + self.assertEqual(str, opts['type']) + self.assertIn('None, opt-1, opt-2', opts['help']) diff --git a/glanceclient/tests/unit/v1/__init__.py b/glanceclient/tests/unit/v1/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/glanceclient/tests/unit/v1/__init__.py diff --git a/glanceclient/tests/unit/v1/test_image_members.py b/glanceclient/tests/unit/v1/test_image_members.py new file mode 100644 index 0000000..d6e3150 --- /dev/null +++ b/glanceclient/tests/unit/v1/test_image_members.py @@ -0,0 +1,125 @@ +# 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 testtools + +from glanceclient.tests import utils +import glanceclient.v1.image_members +import glanceclient.v1.images + + +fixtures = { + '/v1/images/1/members': { + 'GET': ( + {}, + {'members': [ + {'member_id': '1', 'can_share': False}, + ]}, + ), + 'PUT': ({}, None), + }, + '/v1/images/1/members/1': { + 'GET': ( + {}, + {'member': { + 'member_id': '1', + 'can_share': False, + }}, + ), + 'PUT': ({}, None), + 'DELETE': ({}, None), + }, + '/v1/shared-images/1': { + 'GET': ( + {}, + {'shared_images': [ + {'image_id': '1', 'can_share': False}, + ]}, + ), + }, +} + + +class ImageMemberManagerTest(testtools.TestCase): + + def setUp(self): + super(ImageMemberManagerTest, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.mgr = glanceclient.v1.image_members.ImageMemberManager(self.api) + self.image = glanceclient.v1.images.Image(self.api, {'id': '1'}, True) + + def test_list_by_image(self): + members = self.mgr.list(image=self.image) + expect = [('GET', '/v1/images/1/members', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual(1, len(members)) + self.assertEqual('1', members[0].member_id) + self.assertEqual('1', members[0].image_id) + self.assertEqual(False, members[0].can_share) + + def test_list_by_member(self): + resource_class = glanceclient.v1.image_members.ImageMember + member = resource_class(self.api, {'member_id': '1'}, True) + self.mgr.list(member=member) + expect = [('GET', '/v1/shared-images/1', {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_get(self): + member = self.mgr.get(self.image, '1') + expect = [('GET', '/v1/images/1/members/1', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('1', member.member_id) + self.assertEqual('1', member.image_id) + self.assertEqual(False, member.can_share) + + def test_delete(self): + self.mgr.delete('1', '1') + expect = [('DELETE', '/v1/images/1/members/1', {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_create(self): + self.mgr.create(self.image, '1', can_share=True) + expect_body = {'member': {'can_share': True}} + expect = [('PUT', '/v1/images/1/members/1', {}, + sorted(expect_body.items()))] + self.assertEqual(expect, self.api.calls) + + def test_replace(self): + body = [ + {'member_id': '2', 'can_share': False}, + {'member_id': '3'}, + ] + self.mgr.replace(self.image, body) + expect = [('PUT', '/v1/images/1/members', {}, + sorted({'memberships': body}.items()))] + self.assertEqual(expect, self.api.calls) + + def test_replace_objects(self): + body = [ + glanceclient.v1.image_members.ImageMember( + self.mgr, {'member_id': '2', 'can_share': False}, True), + glanceclient.v1.image_members.ImageMember( + self.mgr, {'member_id': '3', 'can_share': True}, True), + ] + self.mgr.replace(self.image, body) + expect_body = { + 'memberships': [ + {'member_id': '2', 'can_share': False}, + {'member_id': '3', 'can_share': True}, + ], + } + expect = [('PUT', '/v1/images/1/members', {}, + sorted(expect_body.items()))] + self.assertEqual(expect, self.api.calls) diff --git a/glanceclient/tests/unit/v1/test_images.py b/glanceclient/tests/unit/v1/test_images.py new file mode 100644 index 0000000..90849d1 --- /dev/null +++ b/glanceclient/tests/unit/v1/test_images.py @@ -0,0 +1,963 @@ +# 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 errno +import json +import testtools + +import six +from six.moves.urllib import parse + +from glanceclient.tests import utils +from glanceclient.v1 import client +from glanceclient.v1 import images +from glanceclient.v1 import shell + + +fixtures = { + '/v1/images': { + 'POST': ( + { + 'location': '/v1/images/1', + 'x-openstack-request-id': 'req-1234', + }, + json.dumps( + {'image': { + 'id': '1', + 'name': 'image-1', + 'container_format': 'ovf', + 'disk_format': 'vhd', + 'owner': 'asdf', + 'size': '1024', + 'min_ram': '512', + 'min_disk': '10', + 'properties': {'a': 'b', 'c': 'd'}, + 'is_public': False, + 'protected': False, + 'deleted': False, + }}, + ), + ), + }, + '/v1/images/detail?limit=20': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?is_public=None&limit=20': { + 'GET': ( + {'x-openstack-request-id': 'req-1234'}, + {'images': [ + { + 'id': 'a', + 'owner': 'A', + 'is_public': 'True', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'owner': 'B', + 'is_public': 'False', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'c', + 'is_public': 'False', + 'name': 'image-3', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?is_public=None&limit=5': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'owner': 'A', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'owner': 'B', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b2', + 'owner': 'B', + 'name': 'image-3', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'c', + 'name': 'image-3', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=5': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'owner': 'A', + 'is_public': 'False', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'owner': 'A', + 'is_public': 'False', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b2', + 'owner': 'B', + 'name': 'image-3', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'c', + 'is_public': 'True', + 'name': 'image-3', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=20&marker=a': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'b', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'c', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=1': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'name': 'image-0', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=1&marker=a': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'b', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=2': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=2&marker=b': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'c', + 'name': 'image-3', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=20&name=foo': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=20&property-ping=pong': + { + 'GET': ( + {}, + {'images': [ + { + 'id': '1', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=20&sort_dir=desc': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + '/v1/images/detail?limit=20&sort_key=name': { + 'GET': ( + {}, + {'images': [ + { + 'id': 'a', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + ]}, + ), + }, + + '/v1/images/1': { + 'HEAD': ( + { + 'x-image-meta-id': '1', + 'x-image-meta-name': 'image-1', + 'x-image-meta-property-arch': 'x86_64', + 'x-image-meta-is_public': 'false', + 'x-image-meta-protected': 'false', + 'x-image-meta-deleted': 'false', + }, + None), + 'GET': ( + {}, + 'XXX', + ), + 'PUT': ( + {}, + json.dumps( + {'image': { + 'id': '1', + 'name': 'image-2', + 'container_format': 'ovf', + 'disk_format': 'vhd', + 'owner': 'asdf', + 'size': '1024', + 'min_ram': '512', + 'min_disk': '10', + 'properties': {'a': 'b', 'c': 'd'}, + 'is_public': False, + 'protected': False, + }}, + ), + ), + 'DELETE': ({}, None), + }, + '/v1/images/2': { + 'HEAD': ( + { + 'x-image-meta-id': '2' + }, + None, + ), + 'GET': ( + { + 'x-image-meta-checksum': 'wrong' + }, + 'YYY', + ), + }, + '/v1/images/3': { + 'HEAD': ( + { + 'x-image-meta-id': '3', + 'x-image-meta-name': u"ni\xf1o" + }, + None, + ), + 'GET': ( + { + 'x-image-meta-checksum': '0745064918b49693cca64d6b6a13d28a' + }, + 'ZZZ', + ), + }, + '/v1/images/4': { + 'HEAD': ( + { + 'x-image-meta-id': '4', + 'x-image-meta-name': 'image-4', + 'x-image-meta-property-arch': 'x86_64', + 'x-image-meta-is_public': 'false', + 'x-image-meta-protected': 'false', + 'x-image-meta-deleted': 'false', + 'x-openstack-request-id': 'req-1234', + }, + None), + 'GET': ( + { + 'x-openstack-request-id': 'req-1234', + }, + 'XXX', + ), + 'PUT': ( + { + 'x-openstack-request-id': 'req-1234', + }, + json.dumps( + {'image': { + 'id': '4', + 'name': 'image-4', + 'container_format': 'ovf', + 'disk_format': 'vhd', + 'owner': 'asdf', + 'size': '1024', + 'min_ram': '512', + 'min_disk': '10', + 'properties': {'a': 'b', 'c': 'd'}, + 'is_public': False, + 'protected': False, + }}, + ), + ), + 'DELETE': ( + { + 'x-openstack-request-id': 'req-1234', + }, + None), + }, + '/v1/images/v2_created_img': { + 'PUT': ( + {}, + json.dumps({ + "image": { + "status": "queued", + "deleted": False, + "container_format": "bare", + "min_ram": 0, + "updated_at": "2013-12-20T01:51:45", + "owner": "foo", + "min_disk": 0, + "is_public": False, + "deleted_at": None, + "id": "v2_created_img", + "size": None, + "name": "bar", + "checksum": None, + "created_at": "2013-12-20T01:50:38", + "disk_format": "qcow2", + "properties": {}, + "protected": False + } + }) + ), + }, +} + + +class ImageManagerTest(testtools.TestCase): + + def setUp(self): + super(ImageManagerTest, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.mgr = images.ImageManager(self.api) + + def test_paginated_list(self): + images = list(self.mgr.list(page_size=2)) + expect = [ + ('GET', '/v1/images/detail?limit=2', {}, None), + ('GET', '/v1/images/detail?limit=2&marker=b', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(3, len(images)) + self.assertEqual('a', images[0].id) + self.assertEqual('b', images[1].id) + self.assertEqual('c', images[2].id) + + def test_list_with_limit_less_than_page_size(self): + results = list(self.mgr.list(page_size=2, limit=1)) + expect = [('GET', '/v1/images/detail?limit=2', {}, None)] + self.assertEqual(1, len(results)) + self.assertEqual(expect, self.api.calls) + + def test_list_with_limit_greater_than_page_size(self): + images = list(self.mgr.list(page_size=1, limit=2)) + expect = [ + ('GET', '/v1/images/detail?limit=1', {}, None), + ('GET', '/v1/images/detail?limit=1&marker=a', {}, None), + ] + self.assertEqual(2, len(images)) + self.assertEqual('a', images[0].id) + self.assertEqual('b', images[1].id) + self.assertEqual(expect, self.api.calls) + + def test_list_with_marker(self): + list(self.mgr.list(marker='a')) + url = '/v1/images/detail?limit=20&marker=a' + expect = [('GET', url, {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_list_with_filter(self): + list(self.mgr.list(filters={'name': "foo"})) + url = '/v1/images/detail?limit=20&name=foo' + expect = [('GET', url, {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_list_with_property_filters(self): + list(self.mgr.list(filters={'properties': {'ping': 'pong'}})) + url = '/v1/images/detail?limit=20&property-ping=pong' + expect = [('GET', url, {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_list_with_sort_dir(self): + list(self.mgr.list(sort_dir='desc')) + url = '/v1/images/detail?limit=20&sort_dir=desc' + expect = [('GET', url, {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_list_with_sort_key(self): + list(self.mgr.list(sort_key='name')) + url = '/v1/images/detail?limit=20&sort_key=name' + expect = [('GET', url, {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_get(self): + image = self.mgr.get('1') + expect = [('HEAD', '/v1/images/1', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('1', image.id) + self.assertEqual('image-1', image.name) + self.assertEqual(False, image.is_public) + self.assertEqual(False, image.protected) + self.assertEqual(False, image.deleted) + self.assertEqual({u'arch': u'x86_64'}, image.properties) + + def test_get_int(self): + image = self.mgr.get(1) + expect = [('HEAD', '/v1/images/1', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('1', image.id) + self.assertEqual('image-1', image.name) + self.assertEqual(False, image.is_public) + self.assertEqual(False, image.protected) + self.assertEqual(False, image.deleted) + self.assertEqual({u'arch': u'x86_64'}, image.properties) + + def test_get_encoding(self): + image = self.mgr.get('3') + self.assertEqual(u"ni\xf1o", image.name) + + def test_get_req_id(self): + params = {'return_req_id': []} + self.mgr.get('4', **params) + expect_req_id = ['req-1234'] + self.assertEqual(expect_req_id, params['return_req_id']) + + def test_data(self): + data = ''.join([b for b in self.mgr.data('1', do_checksum=False)]) + expect = [('GET', '/v1/images/1', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('XXX', data) + + expect += [('GET', '/v1/images/1', {}, None)] + data = ''.join([b for b in self.mgr.data('1')]) + self.assertEqual(expect, self.api.calls) + self.assertEqual('XXX', data) + + def test_data_with_wrong_checksum(self): + data = ''.join([b for b in self.mgr.data('2', do_checksum=False)]) + expect = [('GET', '/v1/images/2', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('YYY', data) + + expect += [('GET', '/v1/images/2', {}, None)] + data = self.mgr.data('2') + self.assertEqual(expect, self.api.calls) + try: + data = ''.join([b for b in data]) + self.fail('data did not raise an error.') + except IOError as e: + self.assertEqual(errno.EPIPE, e.errno) + msg = 'was fd7c5c4fdaa97163ee4ba8842baa537a expected wrong' + self.assertTrue(msg in str(e)) + + def test_data_req_id(self): + params = { + 'do_checksum': False, + 'return_req_id': [], + } + ''.join([b for b in self.mgr.data('4', **params)]) + expect_req_id = ['req-1234'] + self.assertEqual(expect_req_id, params['return_req_id']) + + def test_data_with_checksum(self): + data = ''.join([b for b in self.mgr.data('3', do_checksum=False)]) + expect = [('GET', '/v1/images/3', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('ZZZ', data) + + expect += [('GET', '/v1/images/3', {}, None)] + data = ''.join([b for b in self.mgr.data('3')]) + self.assertEqual(expect, self.api.calls) + self.assertEqual('ZZZ', data) + + def test_delete(self): + self.mgr.delete('1') + expect = [('DELETE', '/v1/images/1', {}, None)] + self.assertEqual(expect, self.api.calls) + + def test_delete_req_id(self): + params = { + 'return_req_id': [] + } + self.mgr.delete('4', **params) + expect = [('DELETE', '/v1/images/4', {}, None)] + self.assertEqual(self.api.calls, expect) + expect_req_id = ['req-1234'] + self.assertEqual(expect_req_id, params['return_req_id']) + + def test_create_without_data(self): + params = { + 'id': '1', + 'name': 'image-1', + 'container_format': 'ovf', + 'disk_format': 'vhd', + 'owner': 'asdf', + 'size': 1024, + 'min_ram': 512, + 'min_disk': 10, + 'copy_from': 'http://example.com', + 'properties': {'a': 'b', 'c': 'd'}, + } + image = self.mgr.create(**params) + expect_headers = { + 'x-image-meta-id': '1', + 'x-image-meta-name': 'image-1', + 'x-image-meta-container_format': 'ovf', + 'x-image-meta-disk_format': 'vhd', + 'x-image-meta-owner': 'asdf', + 'x-image-meta-size': '1024', + 'x-image-meta-min_ram': '512', + 'x-image-meta-min_disk': '10', + 'x-glance-api-copy-from': 'http://example.com', + 'x-image-meta-property-a': 'b', + 'x-image-meta-property-c': 'd', + } + expect = [('POST', '/v1/images', expect_headers, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('1', image.id) + self.assertEqual('image-1', image.name) + self.assertEqual('ovf', image.container_format) + self.assertEqual('vhd', image.disk_format) + self.assertEqual('asdf', image.owner) + self.assertEqual(1024, image.size) + self.assertEqual(512, image.min_ram) + self.assertEqual(10, image.min_disk) + self.assertEqual(False, image.is_public) + self.assertEqual(False, image.protected) + self.assertEqual(False, image.deleted) + self.assertEqual({'a': 'b', 'c': 'd'}, image.properties) + + def test_create_with_data(self): + image_data = six.StringIO('XXX') + self.mgr.create(data=image_data) + expect_headers = {'x-image-meta-size': '3'} + expect = [('POST', '/v1/images', expect_headers, image_data)] + self.assertEqual(expect, self.api.calls) + + def test_create_req_id(self): + params = { + 'id': '4', + 'name': 'image-4', + 'container_format': 'ovf', + 'disk_format': 'vhd', + 'owner': 'asdf', + 'size': 1024, + 'min_ram': 512, + 'min_disk': 10, + 'copy_from': 'http://example.com', + 'properties': {'a': 'b', 'c': 'd'}, + 'return_req_id': [], + } + image = self.mgr.create(**params) + expect_headers = { + 'x-image-meta-id': '4', + 'x-image-meta-name': 'image-4', + 'x-image-meta-container_format': 'ovf', + 'x-image-meta-disk_format': 'vhd', + 'x-image-meta-owner': 'asdf', + 'x-image-meta-size': '1024', + 'x-image-meta-min_ram': '512', + 'x-image-meta-min_disk': '10', + 'x-glance-api-copy-from': 'http://example.com', + 'x-image-meta-property-a': 'b', + 'x-image-meta-property-c': 'd', + } + expect = [('POST', '/v1/images', expect_headers, None)] + self.assertEqual(self.api.calls, expect) + self.assertEqual(image.id, '1') + expect_req_id = ['req-1234'] + self.assertEqual(expect_req_id, params['return_req_id']) + + def test_update(self): + fields = { + 'name': 'image-2', + 'container_format': 'ovf', + 'disk_format': 'vhd', + 'owner': 'asdf', + 'size': 1024, + 'min_ram': 512, + 'min_disk': 10, + 'copy_from': 'http://example.com', + 'properties': {'a': 'b', 'c': 'd'}, + 'deleted': False, + } + image = self.mgr.update('1', **fields) + expect_hdrs = { + 'x-image-meta-name': 'image-2', + 'x-image-meta-container_format': 'ovf', + 'x-image-meta-disk_format': 'vhd', + 'x-image-meta-owner': 'asdf', + 'x-image-meta-size': '1024', + 'x-image-meta-min_ram': '512', + 'x-image-meta-min_disk': '10', + 'x-glance-api-copy-from': 'http://example.com', + 'x-image-meta-property-a': 'b', + 'x-image-meta-property-c': 'd', + 'x-image-meta-deleted': 'False', + 'x-glance-registry-purge-props': 'false', + } + expect = [('PUT', '/v1/images/1', expect_hdrs, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('1', image.id) + self.assertEqual('image-2', image.name) + self.assertEqual(1024, image.size) + self.assertEqual(512, image.min_ram) + self.assertEqual(10, image.min_disk) + + def test_update_with_data(self): + image_data = six.StringIO('XXX') + self.mgr.update('1', data=image_data) + expect_headers = {'x-image-meta-size': '3', + 'x-glance-registry-purge-props': 'false'} + expect = [('PUT', '/v1/images/1', expect_headers, image_data)] + self.assertEqual(expect, self.api.calls) + + def test_update_with_purge_props(self): + self.mgr.update('1', purge_props=True) + expect_headers = {'x-glance-registry-purge-props': 'true'} + expect = [('PUT', '/v1/images/1', expect_headers, None)] + self.assertEqual(expect, self.api.calls) + + def test_update_with_purge_props_false(self): + self.mgr.update('1', purge_props=False) + expect_headers = {'x-glance-registry-purge-props': 'false'} + expect = [('PUT', '/v1/images/1', expect_headers, None)] + self.assertEqual(expect, self.api.calls) + + def test_update_req_id(self): + fields = { + 'purge_props': True, + 'return_req_id': [], + } + self.mgr.update('4', **fields) + expect_headers = {'x-glance-registry-purge-props': 'true'} + expect = [('PUT', '/v1/images/4', expect_headers, None)] + self.assertEqual(self.api.calls, expect) + expect_req_id = ['req-1234'] + self.assertEqual(expect_req_id, fields['return_req_id']) + + def test_image_meta_from_headers_encoding(self): + value = u"ni\xf1o" + if six.PY2: + fields = {"x-image-meta-name": "ni\xc3\xb1o"} + else: + fields = {"x-image-meta-name": value} + headers = self.mgr._image_meta_from_headers(fields) + self.assertEqual(value, headers["name"]) + + def test_image_list_with_owner(self): + images = self.mgr.list(owner='A', page_size=20) + image_list = list(images) + self.assertEqual('A', image_list[0].owner) + self.assertEqual('a', image_list[0].id) + self.assertEqual(1, len(image_list)) + + def test_image_list_with_owner_req_id(self): + fields = { + 'owner': 'A', + 'return_req_id': [], + } + images = self.mgr.list(**fields) + next(images) + self.assertEqual(fields['return_req_id'], ['req-1234']) + + def test_image_list_with_notfound_owner(self): + images = self.mgr.list(owner='X', page_size=20) + self.assertEqual(0, len(list(images))) + + def test_image_list_with_empty_string_owner(self): + images = self.mgr.list(owner='', page_size=20) + image_list = list(images) + self.assertRaises(AttributeError, lambda: image_list[0].owner) + self.assertEqual('c', image_list[0].id) + self.assertEqual(1, len(image_list)) + + def test_image_list_with_unspecified_owner(self): + images = self.mgr.list(owner=None, page_size=5) + image_list = list(images) + self.assertEqual('A', image_list[0].owner) + self.assertEqual('a', image_list[0].id) + self.assertEqual('A', image_list[1].owner) + self.assertEqual('b', image_list[1].id) + self.assertEqual('B', image_list[2].owner) + self.assertEqual('b2', image_list[2].id) + self.assertRaises(AttributeError, lambda: image_list[3].owner) + self.assertEqual('c', image_list[3].id) + self.assertEqual(4, len(image_list)) + + def test_image_list_with_owner_and_limit(self): + images = self.mgr.list(owner='B', page_size=5, limit=1) + image_list = list(images) + self.assertEqual('B', image_list[0].owner) + self.assertEqual('b', image_list[0].id) + self.assertEqual(1, len(image_list)) + + def test_image_list_all_tenants(self): + images = self.mgr.list(is_public=None, page_size=5) + image_list = list(images) + self.assertEqual('A', image_list[0].owner) + self.assertEqual('a', image_list[0].id) + self.assertEqual('B', image_list[1].owner) + self.assertEqual('b', image_list[1].id) + self.assertEqual('B', image_list[2].owner) + self.assertEqual('b2', image_list[2].id) + self.assertRaises(AttributeError, lambda: image_list[3].owner) + self.assertEqual('c', image_list[3].id) + self.assertEqual(4, len(image_list)) + + def test_update_v2_created_image_using_v1(self): + fields_to_update = { + 'name': 'bar', + 'container_format': 'bare', + 'disk_format': 'qcow2', + } + image = self.mgr.update('v2_created_img', **fields_to_update) + expect_hdrs = { + 'x-image-meta-name': 'bar', + 'x-image-meta-container_format': 'bare', + 'x-image-meta-disk_format': 'qcow2', + 'x-glance-registry-purge-props': 'false', + } + expect = [('PUT', '/v1/images/v2_created_img', expect_hdrs, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('v2_created_img', image.id) + self.assertEqual('bar', image.name) + self.assertEqual(0, image.size) + self.assertEqual('bare', image.container_format) + self.assertEqual('qcow2', image.disk_format) + + +class ImageTest(testtools.TestCase): + def setUp(self): + super(ImageTest, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.mgr = images.ImageManager(self.api) + + def test_delete(self): + image = self.mgr.get('1') + image.delete() + expect = [ + ('HEAD', '/v1/images/1', {}, None), + ('HEAD', '/v1/images/1', {}, None), + ('DELETE', '/v1/images/1', {}, None), + ] + self.assertEqual(expect, self.api.calls) + + def test_update(self): + image = self.mgr.get('1') + image.update(name='image-5') + expect = [ + ('HEAD', '/v1/images/1', {}, None), + ('HEAD', '/v1/images/1', {}, None), + ('PUT', '/v1/images/1', + {'x-image-meta-name': 'image-5', + 'x-glance-registry-purge-props': 'false'}, None), + ] + self.assertEqual(expect, self.api.calls) + + def test_data(self): + image = self.mgr.get('1') + data = ''.join([b for b in image.data()]) + expect = [ + ('HEAD', '/v1/images/1', {}, None), + ('HEAD', '/v1/images/1', {}, None), + ('GET', '/v1/images/1', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual('XXX', data) + + data = ''.join([b for b in image.data(do_checksum=False)]) + expect += [('GET', '/v1/images/1', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('XXX', data) + + def test_data_with_wrong_checksum(self): + image = self.mgr.get('2') + data = ''.join([b for b in image.data(do_checksum=False)]) + expect = [ + ('HEAD', '/v1/images/2', {}, None), + ('HEAD', '/v1/images/2', {}, None), + ('GET', '/v1/images/2', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual('YYY', data) + + data = image.data() + expect += [('GET', '/v1/images/2', {}, None)] + self.assertEqual(expect, self.api.calls) + try: + data = ''.join([b for b in image.data()]) + self.fail('data did not raise an error.') + except IOError as e: + self.assertEqual(errno.EPIPE, e.errno) + msg = 'was fd7c5c4fdaa97163ee4ba8842baa537a expected wrong' + self.assertTrue(msg in str(e)) + + def test_data_with_checksum(self): + image = self.mgr.get('3') + data = ''.join([b for b in image.data(do_checksum=False)]) + expect = [ + ('HEAD', '/v1/images/3', {}, None), + ('HEAD', '/v1/images/3', {}, None), + ('GET', '/v1/images/3', {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual('ZZZ', data) + + data = ''.join([b for b in image.data()]) + expect += [('GET', '/v1/images/3', {}, None)] + self.assertEqual(expect, self.api.calls) + self.assertEqual('ZZZ', data) + + +class ParameterFakeAPI(utils.FakeAPI): + image_list = {'images': [ + { + 'id': 'a', + 'name': 'image-1', + 'properties': {'arch': 'x86_64'}, + }, + { + 'id': 'b', + 'name': 'image-2', + 'properties': {'arch': 'x86_64'}, + }, + ]} + + def get(self, url, **kwargs): + self.url = url + return utils.FakeResponse({}), ParameterFakeAPI.image_list + + +class FakeArg(object): + def __init__(self, arg_dict): + self.arg_dict = arg_dict + self.fields = arg_dict.keys() + + def __getattr__(self, name): + if name in self.arg_dict: + return self.arg_dict[name] + else: + return None + + +class UrlParameterTest(testtools.TestCase): + + def setUp(self): + super(UrlParameterTest, self).setUp() + self.api = ParameterFakeAPI({}) + self.gc = client.Client("http://fakeaddress.com") + self.gc.images = images.ImageManager(self.api) + + def test_is_public_list(self): + shell.do_image_list(self.gc, FakeArg({"is_public": "True"})) + parts = parse.urlparse(self.api.url) + qs_dict = parse.parse_qs(parts.query) + self.assertTrue('is_public' in qs_dict) + self.assertTrue(qs_dict['is_public'][0].lower() == "true") diff --git a/glanceclient/tests/unit/v1/test_shell.py b/glanceclient/tests/unit/v1/test_shell.py new file mode 100644 index 0000000..342dce1 --- /dev/null +++ b/glanceclient/tests/unit/v1/test_shell.py @@ -0,0 +1,503 @@ +# Copyright 2013 OpenStack Foundation +# Copyright (C) 2013 Yahoo! 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 argparse +import json +import os +import six +import subprocess +import tempfile +import testtools + +import mock + +from glanceclient import exc +from glanceclient import shell + +import glanceclient.v1.client as client +import glanceclient.v1.images +import glanceclient.v1.shell as v1shell + +from glanceclient.tests import utils + +if six.PY3: + import io + file_type = io.IOBase +else: + file_type = file + +fixtures = { + '/v1/images/96d2c7e1-de4e-4612-8aa2-ba26610c804e': { + 'PUT': ( + { + 'Location': 'http://fakeaddress.com:9292/v1/images/' + '96d2c7e1-de4e-4612-8aa2-ba26610c804e', + 'Etag': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'X-Openstack-Request-Id': + 'req-b645039d-e1c7-43e5-b27b-2d18a173c42b', + 'Date': 'Mon, 29 Apr 2013 10:24:32 GMT' + }, + json.dumps({ + 'image': { + 'status': 'active', 'name': 'testimagerename', + 'deleted': False, + 'container_format': 'ami', + 'created_at': '2013-04-25T15:47:43', + 'disk_format': 'ami', + 'updated_at': '2013-04-29T10:24:32', + 'id': '96d2c7e1-de4e-4612-8aa2-ba26610c804e', + 'min_disk': 0, + 'protected': False, + 'min_ram': 0, + 'checksum': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'owner': '1310db0cce8f40b0987a5acbe139765a', + 'is_public': True, + 'deleted_at': None, + 'properties': { + 'kernel_id': '1b108400-65d8-4762-9ea4-1bf6c7be7568', + 'ramdisk_id': 'b759bee9-0669-4394-a05c-fa2529b1c114' + }, + 'size': 25165824 + } + }) + ), + 'HEAD': ( + { + 'x-image-meta-id': '96d2c7e1-de4e-4612-8aa2-ba26610c804e', + 'x-image-meta-status': 'active' + }, + None + ), + 'GET': ( + { + 'x-image-meta-status': 'active', + 'x-image-meta-owner': '1310db0cce8f40b0987a5acbe139765a', + 'x-image-meta-name': 'cirros-0.3.1-x86_64-uec', + 'x-image-meta-container_format': 'ami', + 'x-image-meta-created_at': '2013-04-25T15:47:43', + 'etag': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'location': 'http://fakeaddress.com:9292/v1/images/' + '96d2c7e1-de4e-4612-8aa2-ba26610c804e', + 'x-image-meta-min_ram': '0', + 'x-image-meta-updated_at': '2013-04-25T15:47:43', + 'x-image-meta-id': '96d2c7e1-de4e-4612-8aa2-ba26610c804e', + 'x-image-meta-property-ramdisk_id': + 'b759bee9-0669-4394-a05c-fa2529b1c114', + 'date': 'Mon, 29 Apr 2013 09:25:17 GMT', + 'x-image-meta-property-kernel_id': + '1b108400-65d8-4762-9ea4-1bf6c7be7568', + 'x-openstack-request-id': + 'req-842735bf-77e8-44a7-bfd1-7d95c52cec7f', + 'x-image-meta-deleted': 'False', + 'x-image-meta-checksum': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'x-image-meta-protected': 'False', + 'x-image-meta-min_disk': '0', + 'x-image-meta-size': '25165824', + 'x-image-meta-is_public': 'True', + 'content-type': 'text/html; charset=UTF-8', + 'x-image-meta-disk_format': 'ami', + }, + None + ) + }, + '/v1/images/44d2c7e1-de4e-4612-8aa2-ba26610c444f': { + 'PUT': ( + { + 'Location': 'http://fakeaddress.com:9292/v1/images/' + '44d2c7e1-de4e-4612-8aa2-ba26610c444f', + 'Etag': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'X-Openstack-Request-Id': + 'req-b645039d-e1c7-43e5-b27b-2d18a173c42b', + 'Date': 'Mon, 29 Apr 2013 10:24:32 GMT' + }, + json.dumps({ + 'image': { + 'status': 'queued', 'name': 'testimagerename', + 'deleted': False, + 'container_format': 'ami', + 'created_at': '2013-04-25T15:47:43', + 'disk_format': 'ami', + 'updated_at': '2013-04-29T10:24:32', + 'id': '44d2c7e1-de4e-4612-8aa2-ba26610c444f', + 'min_disk': 0, + 'protected': False, + 'min_ram': 0, + 'checksum': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'owner': '1310db0cce8f40b0987a5acbe139765a', + 'is_public': True, + 'deleted_at': None, + 'properties': { + 'kernel_id': + '1b108400-65d8-4762-9ea4-1bf6c7be7568', + 'ramdisk_id': + 'b759bee9-0669-4394-a05c-fa2529b1c114' + }, + 'size': 25165824 + } + }) + ), + 'HEAD': ( + { + 'x-image-meta-id': '44d2c7e1-de4e-4612-8aa2-ba26610c444f', + 'x-image-meta-status': 'queued' + }, + None + ), + 'GET': ( + { + 'x-image-meta-status': 'queued', + 'x-image-meta-owner': '1310db0cce8f40b0987a5acbe139765a', + 'x-image-meta-name': 'cirros-0.3.1-x86_64-uec', + 'x-image-meta-container_format': 'ami', + 'x-image-meta-created_at': '2013-04-25T15:47:43', + 'etag': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'location': 'http://fakeaddress.com:9292/v1/images/' + '44d2c7e1-de4e-4612-8aa2-ba26610c444f', + 'x-image-meta-min_ram': '0', + 'x-image-meta-updated_at': '2013-04-25T15:47:43', + 'x-image-meta-id': '44d2c7e1-de4e-4612-8aa2-ba26610c444f', + 'x-image-meta-property-ramdisk_id': + 'b759bee9-0669-4394-a05c-fa2529b1c114', + 'date': 'Mon, 29 Apr 2013 09:25:17 GMT', + 'x-image-meta-property-kernel_id': + '1b108400-65d8-4762-9ea4-1bf6c7be7568', + 'x-openstack-request-id': + 'req-842735bf-77e8-44a7-bfd1-7d95c52cec7f', + 'x-image-meta-deleted': 'False', + 'x-image-meta-checksum': 'f8a2eeee2dc65b3d9b6e63678955bd83', + 'x-image-meta-protected': 'False', + 'x-image-meta-min_disk': '0', + 'x-image-meta-size': '25165824', + 'x-image-meta-is_public': 'True', + 'content-type': 'text/html; charset=UTF-8', + 'x-image-meta-disk_format': 'ami', + }, + None + ) + }, + '/v1/images/detail?limit=20&name=70aa106f-3750-4d7c-a5ce-0a535ac08d0a': { + 'GET': ( + {}, + {'images': [ + { + 'id': '70aa106f-3750-4d7c-a5ce-0a535ac08d0a', + 'name': 'imagedeleted', + 'deleted': True, + 'status': 'deleted', + }, + ]}, + ), + }, + '/v1/images/70aa106f-3750-4d7c-a5ce-0a535ac08d0a': { + 'HEAD': ( + { + 'x-image-meta-id': '70aa106f-3750-4d7c-a5ce-0a535ac08d0a', + 'x-image-meta-status': 'deleted' + }, + None + ) + } +} + + +class ShellInvalidEndpointandParameterTest(utils.TestCase): + + # Patch os.environ to avoid required auth info. + def setUp(self): + """Run before each test.""" + super(ShellInvalidEndpointandParameterTest, self).setUp() + self.old_environment = os.environ.copy() + os.environ = { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + 'OS_TENANT_ID': 'tenant_id', + 'OS_TOKEN_ID': 'test', + 'OS_AUTH_URL': 'http://127.0.0.1:5000/v2.0/', + 'OS_AUTH_TOKEN': 'pass', + 'OS_IMAGE_API_VERSION': '1', + 'OS_REGION_NAME': 'test', + 'OS_IMAGE_URL': 'http://is.invalid'} + + self.shell = shell.OpenStackImagesShell() + + def tearDown(self): + super(ShellInvalidEndpointandParameterTest, self).tearDown() + os.environ = self.old_environment + + def run_command(self, cmd): + self.shell.main(cmd.split()) + + def assert_called(self, method, url, body=None, **kwargs): + return self.shell.cs.assert_called(method, url, body, **kwargs) + + def assert_called_anytime(self, method, url, body=None): + return self.shell.cs.assert_called_anytime(method, url, body) + + def test_image_list_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, self.run_command, 'image-list') + + def test_image_create_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, + self.run_command, 'image-create') + + def test_image_delete_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, + self.run_command, 'image-delete <fake>') + + def test_image_download_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, + self.run_command, 'image-download <fake>') + + def test_members_list_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, + self.run_command, 'member-list --image-id fake') + + def test_image_show_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, + self.run_command, 'image-show --human-readable <IMAGE_ID>') + + def test_member_create_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, + self.run_command, + 'member-create --can-share <IMAGE_ID> <TENANT_ID>') + + def test_member_delete_invalid_endpoint(self): + self.assertRaises( + exc.CommunicationError, + self.run_command, + 'member-delete <IMAGE_ID> <TENANT_ID>') + + @mock.patch('sys.stderr') + def test_image_create_invalid_size_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-create --size 10gb') + + @mock.patch('sys.stderr') + def test_image_create_invalid_ram_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-create --min-ram 10gb') + + @mock.patch('sys.stderr') + def test_image_create_invalid_min_disk_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-create --min-disk 10gb') + + @mock.patch('sys.stderr') + def test_image_update_invalid_size_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-update --size 10gb') + + @mock.patch('sys.stderr') + def test_image_update_invalid_min_disk_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-update --min-disk 10gb') + + @mock.patch('sys.stderr') + def test_image_update_invalid_ram_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-update --min-ram 10gb') + + @mock.patch('sys.stderr') + def test_image_list_invalid_min_size_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-list --size-min 10gb') + + @mock.patch('sys.stderr') + def test_image_list_invalid_max_size_parameter(self, __): + self.assertRaises( + SystemExit, + self.run_command, 'image-list --size-max 10gb') + + +class ShellStdinHandlingTests(testtools.TestCase): + + def _fake_update_func(self, *args, **kwargs): + '''Function to replace glanceclient.images.update, + to determine the parameters that would be supplied with the update + request + ''' + + # Store passed in args + self.collected_args = (args, kwargs) + + # Return the first arg, which is an image, + # as do_image_update expects this. + return args[0] + + def setUp(self): + super(ShellStdinHandlingTests, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.gc = client.Client("http://fakeaddress.com") + self.gc.images = glanceclient.v1.images.ImageManager(self.api) + + # Store real stdin, so it can be restored in tearDown. + self.real_sys_stdin_fd = os.dup(0) + + # Replace stdin with a FD that points to /dev/null. + dev_null = open('/dev/null') + self.dev_null_fd = dev_null.fileno() + os.dup2(dev_null.fileno(), 0) + + # Replace the image update function with a fake, + # so that we can tell if the data field was set correctly. + self.real_update_func = self.gc.images.update + self.collected_args = [] + self.gc.images.update = self._fake_update_func + + def tearDown(self): + """Restore stdin and gc.images.update to their pretest states.""" + super(ShellStdinHandlingTests, self).tearDown() + + def try_close(fd): + try: + os.close(fd) + except OSError: + # Already closed + pass + + # Restore stdin + os.dup2(self.real_sys_stdin_fd, 0) + + # Close duplicate stdin handle + try_close(self.real_sys_stdin_fd) + + # Close /dev/null handle + try_close(self.dev_null_fd) + + # Restore the real image update function + self.gc.images.update = self.real_update_func + + def _do_update(self, image='96d2c7e1-de4e-4612-8aa2-ba26610c804e'): + """call v1/shell's do_image_update function.""" + + v1shell.do_image_update( + self.gc, argparse.Namespace( + image=image, + name='testimagerename', + property={}, + purge_props=False, + human_readable=False, + file=None, + progress=False + ) + ) + + def test_image_delete_deleted(self): + self.assertRaises( + exc.CommandError, + v1shell.do_image_delete, + self.gc, + argparse.Namespace( + images=['70aa106f-3750-4d7c-a5ce-0a535ac08d0a'] + ) + ) + + def test_image_update_closed_stdin(self): + """Supply glanceclient with a closed stdin, and perform an image + update to an active image. Glanceclient should not attempt to read + stdin. + """ + + # NOTE(hughsaunders) Close stdin, which is repointed to /dev/null by + # setUp() + os.close(0) + + self._do_update() + + self.assertTrue( + 'data' not in self.collected_args[1] + or self.collected_args[1]['data'] is None + ) + + def test_image_update_opened_stdin(self): + """Supply glanceclient with a stdin, and perform an image + update to an active image. Glanceclient should not allow it. + """ + + self.assertRaises( + SystemExit, + v1shell.do_image_update, + self.gc, + argparse.Namespace( + image='96d2c7e1-de4e-4612-8aa2-ba26610c804e', + property={}, + ) + ) + + def test_image_update_data_is_read_from_file(self): + """Ensure that data is read from a file.""" + + try: + + # NOTE(hughsaunders) Create a tmpfile, write some data to it and + # set it as stdin + f = open(tempfile.mktemp(), 'w+') + f.write('Some Data') + f.flush() + f.seek(0) + os.dup2(f.fileno(), 0) + + self._do_update('44d2c7e1-de4e-4612-8aa2-ba26610c444f') + + self.assertTrue('data' in self.collected_args[1]) + self.assertIsInstance(self.collected_args[1]['data'], file_type) + self.assertEqual('Some Data', + self.collected_args[1]['data'].read()) + + finally: + try: + f.close() + os.remove(f.name) + except Exception: + pass + + def test_image_update_data_is_read_from_pipe(self): + """Ensure that data is read from a pipe.""" + + try: + + # NOTE(hughsaunders): Setup a pipe, duplicate it to stdin + # ensure it is read. + process = subprocess.Popen(['/bin/echo', 'Some Data'], + stdout=subprocess.PIPE) + os.dup2(process.stdout.fileno(), 0) + + self._do_update('44d2c7e1-de4e-4612-8aa2-ba26610c444f') + + self.assertTrue('data' in self.collected_args[1]) + self.assertIsInstance(self.collected_args[1]['data'], file_type) + self.assertEqual('Some Data\n', + self.collected_args[1]['data'].read()) + + finally: + try: + process.stdout.close() + except OSError: + pass diff --git a/glanceclient/tests/unit/v2/__init__.py b/glanceclient/tests/unit/v2/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/glanceclient/tests/unit/v2/__init__.py diff --git a/glanceclient/tests/unit/v2/test_images.py b/glanceclient/tests/unit/v2/test_images.py new file mode 100644 index 0000000..7fc7558 --- /dev/null +++ b/glanceclient/tests/unit/v2/test_images.py @@ -0,0 +1,1098 @@ +# 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 errno + +import testtools + +from glanceclient import exc +from glanceclient.tests import utils +from glanceclient.v2 import images + +_CHKSUM = '93264c3edf5972c9f1cb309543d38a5c' +_CHKSUM1 = '54264c3edf5972c9f1cb309453d38a46' + +_TAG1 = 'power' +_TAG2 = '64bit' + +_BOGUS_ID = '63e7f218-29de-4477-abdc-8db7c9533188' +_EVERYTHING_ID = '802cbbb7-0379-4c38-853f-37302b5e3d29' +_OWNED_IMAGE_ID = 'a4963502-acc7-42ba-ad60-5aa0962b7faf' +_OWNER_ID = '6bd473f0-79ae-40ad-a927-e07ec37b642f' +_PRIVATE_ID = 'e33560a7-3964-4de5-8339-5a24559f99ab' +_PUBLIC_ID = '857806e7-05b6-48e0-9d40-cb0e6fb727b9' +_SHARED_ID = '331ac905-2a38-44c5-a83d-653db8f08313' +_STATUS_REJECTED_ID = 'f3ea56ff-d7e4-4451-998c-1e3d33539c8e' + +data_fixtures = { + '/v2/schemas/image': { + 'GET': ( + {}, + { + 'name': 'image', + 'properties': { + 'id': {}, + 'name': {}, + 'locations': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'metadata': {'type': 'object'}, + 'url': {'type': 'string'}, + }, + 'required': ['url', 'metadata'], + }, + }, + 'color': {'type': 'string', 'is_base': False}, + }, + 'additionalProperties': {'type': 'string'}, + }, + ), + }, + '/v2/images?limit=%d' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + ]}, + ), + }, + '/v2/images?limit=2': { + 'GET': ( + {}, + { + 'images': [ + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + ], + 'next': ('/v2/images?limit=2&' + 'marker=6f99bf80-2ee6-47cf-acfe-1f1fabb7e810'), + }, + ), + }, + '/v2/images?limit=1': { + 'GET': ( + {}, + { + 'images': [ + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + ], + 'next': ('/v2/images?limit=1&' + 'marker=3a4560a1-e585-443e-9b39-553b46ec92d1'), + }, + ), + }, + ('/v2/images?limit=1&marker=3a4560a1-e585-443e-9b39-553b46ec92d1'): { + 'GET': ( + {}, + {'images': [ + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + ]}, + ), + }, + ('/v2/images?limit=1&marker=6f99bf80-2ee6-47cf-acfe-1f1fabb7e810'): { + 'GET': ( + {}, + {'images': [ + { + 'id': '3f99bf80-2ee6-47cf-acfe-1f1fabb7e811', + 'name': 'image-3', + }, + ]}, + ), + }, + '/v2/images/3a4560a1-e585-443e-9b39-553b46ec92d1': { + 'GET': ( + {}, + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + ), + 'PATCH': ( + {}, + '', + ), + }, + '/v2/images/e7e59ff6-fa2e-4075-87d3-1a1398a07dc3': { + 'GET': ( + {}, + { + 'id': 'e7e59ff6-fa2e-4075-87d3-1a1398a07dc3', + 'name': 'image-3', + 'barney': 'rubble', + 'george': 'jetson', + 'color': 'red', + }, + ), + 'PATCH': ( + {}, + '', + ), + }, + '/v2/images': { + 'POST': ( + {}, + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + ), + }, + '/v2/images/87b634c1-f893-33c9-28a9-e5673c99239a': { + 'DELETE': ( + {}, + { + 'id': '87b634c1-f893-33c9-28a9-e5673c99239a', + }, + ), + }, + '/v2/images/606b0e88-7c5a-4d54-b5bb-046105d4de6f/file': { + 'PUT': ( + {}, + '', + ), + }, + '/v2/images/5cc4bebc-db27-11e1-a1eb-080027cbe205/file': { + 'GET': ( + {}, + 'A', + ), + }, + '/v2/images/66fb18d6-db27-11e1-a1eb-080027cbe205/file': { + 'GET': ( + { + 'content-md5': 'wrong' + }, + 'BB', + ), + }, + '/v2/images/1b1c6366-dd57-11e1-af0f-02163e68b1d8/file': { + 'GET': ( + { + 'content-md5': 'defb99e69a9f1f6e06f15006b1f166ae' + }, + 'CCC', + ), + }, + '/v2/images?limit=%d&visibility=public' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': _PUBLIC_ID, + 'harvey': 'lipshitz', + }, + ]}, + ), + }, + '/v2/images?limit=%d&visibility=private' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': _PRIVATE_ID, + }, + ]}, + ), + }, + '/v2/images?limit=%d&visibility=shared' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': _SHARED_ID, + }, + ]}, + ), + }, + '/v2/images?limit=%d&member_status=rejected' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': _STATUS_REJECTED_ID, + }, + ]}, + ), + }, + '/v2/images?limit=%d&member_status=pending' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': []}, + ), + }, + '/v2/images?limit=%d&owner=%s' % (images.DEFAULT_PAGE_SIZE, _OWNER_ID): { + 'GET': ( + {}, + {'images': [ + { + 'id': _OWNED_IMAGE_ID, + }, + ]}, + ), + }, + '/v2/images?limit=%d&owner=%s' % (images.DEFAULT_PAGE_SIZE, _BOGUS_ID): { + 'GET': ( + {}, + {'images': []}, + ), + }, + '/v2/images?limit=%d&member_status=pending&owner=%s&visibility=shared' + % (images.DEFAULT_PAGE_SIZE, _BOGUS_ID): { + 'GET': ( + {}, + {'images': [ + { + 'id': _EVERYTHING_ID, + }, + ]}, + ), + }, + '/v2/images?checksum=%s&limit=%d' % (_CHKSUM, images.DEFAULT_PAGE_SIZE): { + 'GET': ( + {}, + {'images': [ + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + } + ]}, + ), + }, + '/v2/images?checksum=%s&limit=%d' % (_CHKSUM1, images.DEFAULT_PAGE_SIZE): { + 'GET': ( + {}, + {'images': [ + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + ]}, + ), + }, + '/v2/images?checksum=wrong&limit=%d' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': []}, + ), + }, + '/v2/images?limit=%d&tag=%s' % (images.DEFAULT_PAGE_SIZE, _TAG1): { + 'GET': ( + {}, + {'images': [ + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + } + ]}, + ), + }, + '/v2/images?limit=%d&tag=%s' % (images.DEFAULT_PAGE_SIZE, _TAG2): { + 'GET': ( + {}, + {'images': [ + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + ]}, + ), + }, + '/v2/images?limit=%d&tag=%s&tag=%s' % (images.DEFAULT_PAGE_SIZE, + _TAG1, _TAG2): + { + 'GET': ( + {}, + {'images': [ + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + } + ]}, + ), + }, + '/v2/images?limit=%d&tag=fake' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': []}, + ), + }, + '/v2/images/a2b83adc-888e-11e3-8872-78acc0b951d8': { + 'GET': ( + {}, + { + 'id': 'a2b83adc-888e-11e3-8872-78acc0b951d8', + 'name': 'image-location-tests', + 'locations': [{u'url': u'http://foo.com/', + u'metadata': {u'foo': u'foometa'}}, + {u'url': u'http://bar.com/', + u'metadata': {u'bar': u'barmeta'}}], + }, + ), + 'PATCH': ( + {}, + '', + ) + }, + '/v2/images?limit=%d&os_distro=NixOS' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '8b052954-c76c-4e02-8e90-be89a70183a8', + 'name': 'image-5', + 'os_distro': 'NixOS', + }, + ]}, + ), + }, + '/v2/images?limit=%d&my_little_property=cant_be_this_cute' % + images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': []}, + ), + }, + '/v2/images?limit=%d&sort_key=name' % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + ]}, + ), + }, + '/v2/images?limit=%d&sort_key=name&sort_key=id' + % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image', + }, + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image', + }, + ]}, + ), + }, + '/v2/images?limit=%d&sort_dir=desc&sort_key=id' + % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + ]}, + ), + }, + '/v2/images?limit=%d&sort_dir=desc&sort_key=name&sort_key=id' + % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + ]}, + ), + }, + '/v2/images?limit=%d&sort_dir=desc&sort_dir=asc&sort_key=name&sort_key=id' + % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + ]}, + ), + }, + '/v2/images?limit=%d&sort=name%%3Adesc%%2Csize%%3Aasc' + % images.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'images': [ + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'name': 'image-2', + }, + { + 'id': '2a4560b2-e585-443e-9b39-553b46ec92d1', + 'name': 'image-1', + }, + ]}, + ), + }, +} + +schema_fixtures = { + 'image': { + 'GET': ( + {}, + { + 'name': 'image', + 'properties': { + 'id': {}, + 'name': {}, + 'locations': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'metadata': {'type': 'object'}, + 'url': {'type': 'string'}, + }, + 'required': ['url', 'metadata'], + } + }, + 'color': {'type': 'string', 'is_base': False}, + 'tags': {'type': 'array'}, + }, + 'additionalProperties': {'type': 'string'}, + } + ) + } +} + + +class TestController(testtools.TestCase): + def setUp(self): + super(TestController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = images.Controller(self.api, self.schema_api) + + def test_list_images(self): + # NOTE(bcwaldon):cast to list since the controller returns a generator + images = list(self.controller.list()) + self.assertEqual('3a4560a1-e585-443e-9b39-553b46ec92d1', images[0].id) + self.assertEqual('image-1', images[0].name) + self.assertEqual('6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', images[1].id) + self.assertEqual('image-2', images[1].name) + + def test_list_images_paginated(self): + # NOTE(bcwaldon):cast to list since the controller returns a generator + images = list(self.controller.list(page_size=1)) + self.assertEqual('3a4560a1-e585-443e-9b39-553b46ec92d1', images[0].id) + self.assertEqual('image-1', images[0].name) + self.assertEqual('6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', images[1].id) + self.assertEqual('image-2', images[1].name) + + def test_list_images_paginated_with_limit(self): + # NOTE(bcwaldon):cast to list since the controller returns a generator + images = list(self.controller.list(limit=3, page_size=2)) + self.assertEqual('3a4560a1-e585-443e-9b39-553b46ec92d1', images[0].id) + self.assertEqual('image-1', images[0].name) + self.assertEqual('6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', images[1].id) + self.assertEqual('image-2', images[1].name) + self.assertEqual('3f99bf80-2ee6-47cf-acfe-1f1fabb7e811', images[2].id) + self.assertEqual('image-3', images[2].name) + self.assertEqual(3, len(images)) + + def test_list_images_visibility_public(self): + filters = {'filters': {'visibility': 'public'}} + images = list(self.controller.list(**filters)) + self.assertEqual(_PUBLIC_ID, images[0].id) + + def test_list_images_visibility_private(self): + filters = {'filters': {'visibility': 'private'}} + images = list(self.controller.list(**filters)) + self.assertEqual(_PRIVATE_ID, images[0].id) + + def test_list_images_visibility_shared(self): + filters = {'filters': {'visibility': 'shared'}} + images = list(self.controller.list(**filters)) + self.assertEqual(_SHARED_ID, images[0].id) + + def test_list_images_member_status_rejected(self): + filters = {'filters': {'member_status': 'rejected'}} + images = list(self.controller.list(**filters)) + self.assertEqual(_STATUS_REJECTED_ID, images[0].id) + + def test_list_images_for_owner(self): + filters = {'filters': {'owner': _OWNER_ID}} + images = list(self.controller.list(**filters)) + self.assertEqual(_OWNED_IMAGE_ID, images[0].id) + + def test_list_images_for_checksum_single_image(self): + fake_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + filters = {'filters': {'checksum': _CHKSUM}} + images = list(self.controller.list(**filters)) + self.assertEqual(1, len(images)) + self.assertEqual('%s' % fake_id, images[0].id) + + def test_list_images_for_checksum_multiple_images(self): + fake_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + fake_id2 = '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810' + filters = {'filters': {'checksum': _CHKSUM1}} + images = list(self.controller.list(**filters)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % fake_id1, images[0].id) + self.assertEqual('%s' % fake_id2, images[1].id) + + def test_list_images_for_wrong_checksum(self): + filters = {'filters': {'checksum': 'wrong'}} + images = list(self.controller.list(**filters)) + self.assertEqual(0, len(images)) + + def test_list_images_for_bogus_owner(self): + filters = {'filters': {'owner': _BOGUS_ID}} + images = list(self.controller.list(**filters)) + self.assertEqual([], images) + + def test_list_images_for_bunch_of_filters(self): + filters = {'filters': {'owner': _BOGUS_ID, + 'visibility': 'shared', + 'member_status': 'pending'}} + images = list(self.controller.list(**filters)) + self.assertEqual(_EVERYTHING_ID, images[0].id) + + def test_list_images_filters_encoding(self): + filters = {"owner": u"ni\xf1o"} + try: + list(self.controller.list(filters=filters)) + except KeyError: + # NOTE(flaper87): It raises KeyError because there's + # no fixture supporting this query: + # /v2/images?owner=ni%C3%B1o&limit=20 + # We just want to make sure filters are correctly encoded. + pass + self.assertEqual(b"ni\xc3\xb1o", filters["owner"]) + + def test_list_images_for_tag_single_image(self): + img_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + filters = {'filters': {'tag': [_TAG1]}} + images = list(self.controller.list(**filters)) + self.assertEqual(1, len(images)) + self.assertEqual('%s' % img_id, images[0].id) + + def test_list_images_for_tag_multiple_images(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + img_id2 = '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810' + filters = {'filters': {'tag': [_TAG2]}} + images = list(self.controller.list(**filters)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % img_id1, images[0].id) + self.assertEqual('%s' % img_id2, images[1].id) + + def test_list_images_for_multi_tags(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + filters = {'filters': {'tag': [_TAG1, _TAG2]}} + images = list(self.controller.list(**filters)) + self.assertEqual(1, len(images)) + self.assertEqual('%s' % img_id1, images[0].id) + + def test_list_images_for_non_existent_tag(self): + filters = {'filters': {'tag': ['fake']}} + images = list(self.controller.list(**filters)) + self.assertEqual(0, len(images)) + + def test_list_images_for_invalid_tag(self): + filters = {'filters': {'tag': [[]]}} + + self.assertRaises(exc.HTTPBadRequest, + list, + self.controller.list(**filters)) + + def test_list_images_with_single_sort_key(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + sort_key = 'name' + images = list(self.controller.list(sort_key=sort_key)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % img_id1, images[0].id) + + def test_list_with_multiple_sort_keys(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + sort_key = ['name', 'id'] + images = list(self.controller.list(sort_key=sort_key)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % img_id1, images[0].id) + + def test_list_images_with_desc_sort_dir(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + sort_key = 'id' + sort_dir = 'desc' + images = list(self.controller.list(sort_key=sort_key, + sort_dir=sort_dir)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % img_id1, images[1].id) + + def test_list_images_with_multiple_sort_keys_and_one_sort_dir(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + sort_key = ['name', 'id'] + sort_dir = 'desc' + images = list(self.controller.list(sort_key=sort_key, + sort_dir=sort_dir)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % img_id1, images[1].id) + + def test_list_images_with_multiple_sort_dirs(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + sort_key = ['name', 'id'] + sort_dir = ['desc', 'asc'] + images = list(self.controller.list(sort_key=sort_key, + sort_dir=sort_dir)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % img_id1, images[1].id) + + def test_list_images_with_new_sorting_syntax(self): + img_id1 = '2a4560b2-e585-443e-9b39-553b46ec92d1' + sort = 'name:desc,size:asc' + images = list(self.controller.list(sort=sort)) + self.assertEqual(2, len(images)) + self.assertEqual('%s' % img_id1, images[1].id) + + def test_list_images_sort_dirs_fewer_than_keys(self): + sort_key = ['name', 'id', 'created_at'] + sort_dir = ['desc', 'asc'] + self.assertRaises(exc.HTTPBadRequest, + list, + self.controller.list( + sort_key=sort_key, + sort_dir=sort_dir)) + + def test_list_images_combined_syntax(self): + sort_key = ['name', 'id'] + sort_dir = ['desc', 'asc'] + sort = 'name:asc' + self.assertRaises(exc.HTTPBadRequest, + list, + self.controller.list( + sort=sort, + sort_key=sort_key, + sort_dir=sort_dir)) + + def test_list_images_new_sorting_syntax_invalid_key(self): + sort = 'INVALID:asc' + self.assertRaises(exc.HTTPBadRequest, + list, + self.controller.list( + sort=sort)) + + def test_list_images_new_sorting_syntax_invalid_direction(self): + sort = 'name:INVALID' + self.assertRaises(exc.HTTPBadRequest, + list, + self.controller.list( + sort=sort)) + + def test_list_images_for_property(self): + filters = {'filters': dict([('os_distro', 'NixOS')])} + images = list(self.controller.list(**filters)) + self.assertEqual(1, len(images)) + + def test_list_images_for_non_existent_property(self): + filters = {'filters': dict([('my_little_property', + 'cant_be_this_cute')])} + images = list(self.controller.list(**filters)) + self.assertEqual(0, len(images)) + + def test_get_image(self): + image = self.controller.get('3a4560a1-e585-443e-9b39-553b46ec92d1') + self.assertEqual('3a4560a1-e585-443e-9b39-553b46ec92d1', image.id) + self.assertEqual('image-1', image.name) + + def test_create_image(self): + properties = { + 'name': 'image-1' + } + image = self.controller.create(**properties) + self.assertEqual('3a4560a1-e585-443e-9b39-553b46ec92d1', image.id) + self.assertEqual('image-1', image.name) + + def test_create_bad_additionalProperty_type(self): + properties = { + 'name': 'image-1', + 'bad_prop': True, + } + with testtools.ExpectedException(TypeError): + self.controller.create(**properties) + + def test_delete_image(self): + self.controller.delete('87b634c1-f893-33c9-28a9-e5673c99239a') + expect = [ + ('DELETE', + '/v2/images/87b634c1-f893-33c9-28a9-e5673c99239a', + {}, + None)] + self.assertEqual(expect, self.api.calls) + + def test_data_upload(self): + image_data = 'CCC' + image_id = '606b0e88-7c5a-4d54-b5bb-046105d4de6f' + self.controller.upload(image_id, image_data) + expect = [('PUT', '/v2/images/%s/file' % image_id, + {'Content-Type': 'application/octet-stream'}, + image_data)] + self.assertEqual(expect, self.api.calls) + + def test_data_upload_w_size(self): + image_data = 'CCC' + image_id = '606b0e88-7c5a-4d54-b5bb-046105d4de6f' + self.controller.upload(image_id, image_data, image_size=3) + body = {'image_data': image_data, + 'image_size': 3} + expect = [('PUT', '/v2/images/%s/file' % image_id, + {'Content-Type': 'application/octet-stream'}, + sorted(body.items()))] + self.assertEqual(expect, self.api.calls) + + def test_data_without_checksum(self): + body = self.controller.data('5cc4bebc-db27-11e1-a1eb-080027cbe205', + do_checksum=False) + body = ''.join([b for b in body]) + self.assertEqual('A', body) + + body = self.controller.data('5cc4bebc-db27-11e1-a1eb-080027cbe205') + body = ''.join([b for b in body]) + self.assertEqual('A', body) + + def test_data_with_wrong_checksum(self): + body = self.controller.data('66fb18d6-db27-11e1-a1eb-080027cbe205', + do_checksum=False) + body = ''.join([b for b in body]) + self.assertEqual('BB', body) + + body = self.controller.data('66fb18d6-db27-11e1-a1eb-080027cbe205') + try: + body = ''.join([b for b in body]) + self.fail('data did not raise an error.') + except IOError as e: + self.assertEqual(errno.EPIPE, e.errno) + msg = 'was 9d3d9048db16a7eee539e93e3618cbe7 expected wrong' + self.assertTrue(msg in str(e)) + + def test_data_with_checksum(self): + body = self.controller.data('1b1c6366-dd57-11e1-af0f-02163e68b1d8', + do_checksum=False) + body = ''.join([b for b in body]) + self.assertEqual('CCC', body) + + body = self.controller.data('1b1c6366-dd57-11e1-af0f-02163e68b1d8') + body = ''.join([b for b in body]) + self.assertEqual('CCC', body) + + def test_update_replace_prop(self): + image_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + params = {'name': 'pong'} + image = self.controller.update(image_id, **params) + expect_hdrs = { + 'Content-Type': 'application/openstack-images-v2.1-json-patch', + } + expect_body = [[('op', 'replace'), ('path', '/name'), + ('value', 'pong')]] + expect = [ + ('GET', '/v2/images/%s' % image_id, {}, None), + ('PATCH', '/v2/images/%s' % image_id, expect_hdrs, expect_body), + ('GET', '/v2/images/%s' % image_id, {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(image_id, image.id) + # NOTE(bcwaldon):due to limitations of our fake api framework, the name + # will not actually change - yet in real life it will... + self.assertEqual('image-1', image.name) + + def test_update_add_prop(self): + image_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + params = {'finn': 'human'} + image = self.controller.update(image_id, **params) + expect_hdrs = { + 'Content-Type': 'application/openstack-images-v2.1-json-patch', + } + expect_body = [[('op', 'add'), ('path', '/finn'), ('value', 'human')]] + expect = [ + ('GET', '/v2/images/%s' % image_id, {}, None), + ('PATCH', '/v2/images/%s' % image_id, expect_hdrs, expect_body), + ('GET', '/v2/images/%s' % image_id, {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(image_id, image.id) + # NOTE(bcwaldon):due to limitations of our fake api framework, the name + # will not actually change - yet in real life it will... + self.assertEqual('image-1', image.name) + + def test_update_remove_prop(self): + image_id = 'e7e59ff6-fa2e-4075-87d3-1a1398a07dc3' + remove_props = ['barney'] + image = self.controller.update(image_id, remove_props) + expect_hdrs = { + 'Content-Type': 'application/openstack-images-v2.1-json-patch', + } + expect_body = [[('op', 'remove'), ('path', '/barney')]] + expect = [ + ('GET', '/v2/images/%s' % image_id, {}, None), + ('PATCH', '/v2/images/%s' % image_id, expect_hdrs, expect_body), + ('GET', '/v2/images/%s' % image_id, {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(image_id, image.id) + # NOTE(bcwaldon):due to limitations of our fake api framework, the name + # will not actually change - yet in real life it will... + self.assertEqual('image-3', image.name) + + def test_update_replace_remove_same_prop(self): + image_id = 'e7e59ff6-fa2e-4075-87d3-1a1398a07dc3' + # Updating a property takes precedence over removing a property + params = {'barney': 'miller'} + remove_props = ['barney'] + image = self.controller.update(image_id, remove_props, **params) + expect_hdrs = { + 'Content-Type': 'application/openstack-images-v2.1-json-patch', + } + expect_body = ([[('op', 'replace'), ('path', '/barney'), + ('value', 'miller')]]) + expect = [ + ('GET', '/v2/images/%s' % image_id, {}, None), + ('PATCH', '/v2/images/%s' % image_id, expect_hdrs, expect_body), + ('GET', '/v2/images/%s' % image_id, {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(image_id, image.id) + # NOTE(bcwaldon):due to limitations of our fake api framework, the name + # will not actually change - yet in real life it will... + self.assertEqual('image-3', image.name) + + def test_update_add_remove_same_prop(self): + image_id = 'e7e59ff6-fa2e-4075-87d3-1a1398a07dc3' + # Adding a property takes precedence over removing a property + params = {'finn': 'human'} + remove_props = ['finn'] + image = self.controller.update(image_id, remove_props, **params) + expect_hdrs = { + 'Content-Type': 'application/openstack-images-v2.1-json-patch', + } + expect_body = [[('op', 'add'), ('path', '/finn'), ('value', 'human')]] + expect = [ + ('GET', '/v2/images/%s' % image_id, {}, None), + ('PATCH', '/v2/images/%s' % image_id, expect_hdrs, expect_body), + ('GET', '/v2/images/%s' % image_id, {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(image_id, image.id) + # NOTE(bcwaldon):due to limitations of our fake api framework, the name + # will not actually change - yet in real life it will... + self.assertEqual('image-3', image.name) + + def test_update_bad_additionalProperty_type(self): + image_id = 'e7e59ff6-fa2e-4075-87d3-1a1398a07dc3' + params = {'name': 'pong', 'bad_prop': False} + with testtools.ExpectedException(TypeError): + self.controller.update(image_id, **params) + + def test_update_add_custom_property(self): + image_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + params = {'color': 'red'} + image = self.controller.update(image_id, **params) + expect_hdrs = { + 'Content-Type': 'application/openstack-images-v2.1-json-patch', + } + expect_body = [[('op', 'add'), ('path', '/color'), ('value', 'red')]] + expect = [ + ('GET', '/v2/images/%s' % image_id, {}, None), + ('PATCH', '/v2/images/%s' % image_id, expect_hdrs, expect_body), + ('GET', '/v2/images/%s' % image_id, {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(image_id, image.id) + + def test_update_replace_custom_property(self): + image_id = 'e7e59ff6-fa2e-4075-87d3-1a1398a07dc3' + params = {'color': 'blue'} + image = self.controller.update(image_id, **params) + expect_hdrs = { + 'Content-Type': 'application/openstack-images-v2.1-json-patch', + } + expect_body = [[('op', 'replace'), ('path', '/color'), + ('value', 'blue')]] + expect = [ + ('GET', '/v2/images/%s' % image_id, {}, None), + ('PATCH', '/v2/images/%s' % image_id, expect_hdrs, expect_body), + ('GET', '/v2/images/%s' % image_id, {}, None), + ] + self.assertEqual(expect, self.api.calls) + self.assertEqual(image_id, image.id) + + def test_location_ops_when_server_disabled_location_ops(self): + # Location operations should not be allowed if server has not + # enabled location related operations + image_id = '3a4560a1-e585-443e-9b39-553b46ec92d1' + estr = 'The administrator has disabled API access to image locations' + url = 'http://bar.com/' + meta = {'bar': 'barmeta'} + + e = self.assertRaises(exc.HTTPBadRequest, + self.controller.add_location, + image_id, url, meta) + self.assertTrue(estr in str(e)) + + e = self.assertRaises(exc.HTTPBadRequest, + self.controller.delete_locations, + image_id, set([url])) + self.assertTrue(estr in str(e)) + + e = self.assertRaises(exc.HTTPBadRequest, + self.controller.update_location, + image_id, url, meta) + self.assertTrue(estr in str(e)) + + def _empty_get(self, image_id): + return ('GET', '/v2/images/%s' % image_id, {}, None) + + def _patch_req(self, image_id, patch_body): + c_type = 'application/openstack-images-v2.1-json-patch' + data = [sorted(d.items()) for d in patch_body] + return ('PATCH', + '/v2/images/%s' % image_id, + {'Content-Type': c_type}, + data) + + def test_add_location(self): + image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' + new_loc = {'url': 'http://spam.com/', 'metadata': {'spam': 'ham'}} + add_patch = {'path': '/locations/-', 'value': new_loc, 'op': 'add'} + self.controller.add_location(image_id, **new_loc) + self.assertEqual(self.api.calls, [ + self._empty_get(image_id), + self._patch_req(image_id, [add_patch]), + self._empty_get(image_id) + ]) + + def test_add_duplicate_location(self): + image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' + new_loc = {'url': 'http://foo.com/', 'metadata': {'foo': 'newfoo'}} + err_str = 'A location entry at %s already exists' % new_loc['url'] + + err = self.assertRaises(exc.HTTPConflict, + self.controller.add_location, + image_id, **new_loc) + self.assertIn(err_str, str(err)) + + def test_remove_location(self): + image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' + url_set = set(['http://foo.com/', 'http://bar.com/']) + del_patches = [{'path': '/locations/1', 'op': 'remove'}, + {'path': '/locations/0', 'op': 'remove'}] + self.controller.delete_locations(image_id, url_set) + self.assertEqual(self.api.calls, [ + self._empty_get(image_id), + self._patch_req(image_id, del_patches) + ]) + + def test_remove_missing_location(self): + image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' + url_set = set(['http://spam.ham/']) + err_str = 'Unknown URL(s): %s' % list(url_set) + + err = self.assertRaises(exc.HTTPNotFound, + self.controller.delete_locations, + image_id, url_set) + self.assertTrue(err_str in str(err)) + + def test_update_location(self): + image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' + new_loc = {'url': 'http://foo.com/', 'metadata': {'spam': 'ham'}} + fixture_idx = '/v2/images/%s' % (image_id) + orig_locations = data_fixtures[fixture_idx]['GET'][1]['locations'] + loc_map = dict([(l['url'], l) for l in orig_locations]) + loc_map[new_loc['url']] = new_loc + mod_patch = [{'path': '/locations', 'op': 'replace', + 'value': []}, + {'path': '/locations', 'op': 'replace', + 'value': list(loc_map.values())}] + self.controller.update_location(image_id, **new_loc) + self.assertEqual(self.api.calls, [ + self._empty_get(image_id), + self._patch_req(image_id, mod_patch), + self._empty_get(image_id) + ]) + + def test_update_tags(self): + image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' + tag_map = {'tags': ['tag01', 'tag02', 'tag03']} + + image = self.controller.update(image_id, **tag_map) + + expected_body = [{'path': '/tags', 'op': 'replace', + 'value': tag_map['tags']}] + expected = [ + self._empty_get(image_id), + self._patch_req(image_id, expected_body), + self._empty_get(image_id) + ] + self.assertEqual(expected, self.api.calls) + self.assertEqual(image_id, image.id) + + def test_update_missing_location(self): + image_id = 'a2b83adc-888e-11e3-8872-78acc0b951d8' + new_loc = {'url': 'http://spam.com/', 'metadata': {'spam': 'ham'}} + err_str = 'Unknown URL: %s' % new_loc['url'] + err = self.assertRaises(exc.HTTPNotFound, + self.controller.update_location, + image_id, **new_loc) + self.assertTrue(err_str in str(err)) diff --git a/glanceclient/tests/unit/v2/test_members.py b/glanceclient/tests/unit/v2/test_members.py new file mode 100644 index 0000000..cf56a36 --- /dev/null +++ b/glanceclient/tests/unit/v2/test_members.py @@ -0,0 +1,120 @@ +# Copyright 2013 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 testtools + +from glanceclient.tests import utils +from glanceclient.v2 import image_members + + +IMAGE = '3a4560a1-e585-443e-9b39-553b46ec92d1' +MEMBER = '11223344-5566-7788-9911-223344556677' + + +data_fixtures = { + '/v2/images/{image}/members'.format(image=IMAGE): { + 'GET': ( + {}, + {'members': [ + { + 'image_id': IMAGE, + 'member_id': MEMBER, + }, + ]}, + ), + 'POST': ( + {}, + { + 'image_id': IMAGE, + 'member_id': MEMBER, + 'status': 'pending' + } + ) + }, + '/v2/images/{image}/members/{mem}'.format(image=IMAGE, mem=MEMBER): { + 'DELETE': ( + {}, + None, + ), + 'PUT': ( + {}, + { + 'image_id': IMAGE, + 'member_id': MEMBER, + 'status': 'accepted' + } + ), + } +} + +schema_fixtures = { + 'member': { + 'GET': ( + {}, + { + 'name': 'member', + 'properties': { + 'image_id': {}, + 'member_id': {} + } + }, + ) + } +} + + +class TestController(testtools.TestCase): + def setUp(self): + super(TestController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = image_members.Controller(self.api, self.schema_api) + + def test_list_image_members(self): + image_id = IMAGE + #NOTE(iccha): cast to list since the controller returns a generator + image_members = list(self.controller.list(image_id)) + self.assertEqual(IMAGE, image_members[0].image_id) + self.assertEqual(MEMBER, image_members[0].member_id) + + def test_delete_image_member(self): + image_id = IMAGE + member_id = MEMBER + self.controller.delete(image_id, member_id) + expect = [ + ('DELETE', + '/v2/images/{image}/members/{mem}'.format(image=IMAGE, + mem=MEMBER), + {}, + None)] + self.assertEqual(expect, self.api.calls) + + def test_update_image_members(self): + image_id = IMAGE + member_id = MEMBER + status = 'accepted' + image_member = self.controller.update(image_id, member_id, status) + self.assertEqual(IMAGE, image_member.image_id) + self.assertEqual(MEMBER, image_member.member_id) + self.assertEqual(status, image_member.status) + + def test_create_image_members(self): + image_id = IMAGE + member_id = MEMBER + status = 'pending' + image_member = self.controller.create(image_id, member_id) + self.assertEqual(IMAGE, image_member.image_id) + self.assertEqual(MEMBER, image_member.member_id) + self.assertEqual(status, image_member.status) diff --git a/glanceclient/tests/unit/v2/test_metadefs_namespaces.py b/glanceclient/tests/unit/v2/test_metadefs_namespaces.py new file mode 100644 index 0000000..ecc05ee --- /dev/null +++ b/glanceclient/tests/unit/v2/test_metadefs_namespaces.py @@ -0,0 +1,674 @@ +# 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 testtools + +from glanceclient.tests import utils +from glanceclient.v2 import metadefs + +NAMESPACE1 = 'Namespace1' +NAMESPACE2 = 'Namespace2' +NAMESPACE3 = 'Namespace3' +NAMESPACE4 = 'Namespace4' +NAMESPACE5 = 'Namespace5' +NAMESPACE6 = 'Namespace6' +NAMESPACE7 = 'Namespace7' +NAMESPACE8 = 'Namespace8' +NAMESPACENEW = 'NamespaceNew' +RESOURCE_TYPE1 = 'ResourceType1' +RESOURCE_TYPE2 = 'ResourceType2' +OBJECT1 = 'Object1' +PROPERTY1 = 'Property1' +PROPERTY2 = 'Property2' + + +def _get_namespace_fixture(ns_name, rt_name=RESOURCE_TYPE1, **kwargs): + ns = { + "display_name": "Flavor Quota", + "description": "DESCRIPTION1", + "self": "/v2/metadefs/namespaces/%s" % ns_name, + "namespace": ns_name, + "visibility": "public", + "protected": True, + "owner": "admin", + "resource_types": [ + { + "name": rt_name + } + ], + "schema": "/v2/schemas/metadefs/namespace", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + + ns.update(kwargs) + + return ns + +data_fixtures = { + "/v2/metadefs/namespaces?limit=20": { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=20", + "namespaces": [ + _get_namespace_fixture(NAMESPACE1), + _get_namespace_fixture(NAMESPACE2), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces?limit=1": { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=1", + "namespaces": [ + _get_namespace_fixture(NAMESPACE7), + ], + "schema": "/v2/schemas/metadefs/namespaces", + "next": "/v2/metadefs/namespaces?marker=%s&limit=1" + % NAMESPACE7, + } + ) + }, + "/v2/metadefs/namespaces?limit=1&marker=%s" % NAMESPACE7: { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=2", + "namespaces": [ + _get_namespace_fixture(NAMESPACE8), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces?limit=2&marker=%s" % NAMESPACE6: { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=2", + "namespaces": [ + _get_namespace_fixture(NAMESPACE7), + _get_namespace_fixture(NAMESPACE8), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces?limit=20&sort_dir=asc": { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=1", + "namespaces": [ + _get_namespace_fixture(NAMESPACE1), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces?limit=20&sort_key=created_at": { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=1", + "namespaces": [ + _get_namespace_fixture(NAMESPACE1), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces?limit=20&resource_types=%s" % RESOURCE_TYPE1: { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=20", + "namespaces": [ + _get_namespace_fixture(NAMESPACE3), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces?limit=20&resource_types=" + "%s%%2C%s" % (RESOURCE_TYPE1, RESOURCE_TYPE2): { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=20", + "namespaces": [ + _get_namespace_fixture(NAMESPACE4), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces?limit=20&visibility=private": { + "GET": ( + {}, + { + "first": "/v2/metadefs/namespaces?limit=20", + "namespaces": [ + _get_namespace_fixture(NAMESPACE5), + ], + "schema": "/v2/schemas/metadefs/namespaces" + } + ) + }, + "/v2/metadefs/namespaces": { + "POST": ( + {}, + { + "display_name": "Flavor Quota", + "description": "DESCRIPTION1", + "self": "/v2/metadefs/namespaces/%s" % 'NamespaceNew', + "namespace": 'NamespaceNew', + "visibility": "public", + "protected": True, + "owner": "admin", + "schema": "/v2/schemas/metadefs/namespace", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + ) + }, + "/v2/metadefs/namespaces/%s" % NAMESPACE1: { + "GET": ( + {}, + { + "display_name": "Flavor Quota", + "description": "DESCRIPTION1", + "objects": [ + { + "description": "DESCRIPTION2", + "name": "OBJECT1", + "self": "/v2/metadefs/namespaces/%s/objects/" % + OBJECT1, + "required": [], + "properties": { + PROPERTY1: { + "type": "integer", + "description": "DESCRIPTION3", + "title": "Quota: CPU Shares" + }, + PROPERTY2: { + "minimum": 1000, + "type": "integer", + "description": "DESCRIPTION4", + "maximum": 1000000, + "title": "Quota: CPU Period" + }, + }, + "schema": "/v2/schemas/metadefs/object" + } + ], + "self": "/v2/metadefs/namespaces/%s" % NAMESPACE1, + "namespace": NAMESPACE1, + "visibility": "public", + "protected": True, + "owner": "admin", + "resource_types": [ + { + "name": RESOURCE_TYPE1 + } + ], + "schema": "/v2/schemas/metadefs/namespace", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + ), + "PUT": ( + {}, + { + "display_name": "Flavor Quota", + "description": "DESCRIPTION1", + "objects": [ + { + "description": "DESCRIPTION2", + "name": "OBJECT1", + "self": "/v2/metadefs/namespaces/%s/objects/" % + OBJECT1, + "required": [], + "properties": { + PROPERTY1: { + "type": "integer", + "description": "DESCRIPTION3", + "title": "Quota: CPU Shares" + }, + PROPERTY2: { + "minimum": 1000, + "type": "integer", + "description": "DESCRIPTION4", + "maximum": 1000000, + "title": "Quota: CPU Period" + }, + }, + "schema": "/v2/schemas/metadefs/object" + } + ], + "self": "/v2/metadefs/namespaces/%s" % NAMESPACENEW, + "namespace": NAMESPACENEW, + "visibility": "public", + "protected": True, + "owner": "admin", + "resource_types": [ + { + "name": RESOURCE_TYPE1 + } + ], + "schema": "/v2/schemas/metadefs/namespace", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + ), + "DELETE": ( + {}, + {} + ) + }, + "/v2/metadefs/namespaces/%s?resource_type=%s" % (NAMESPACE6, + RESOURCE_TYPE1): + { + "GET": ( + {}, + { + "display_name": "Flavor Quota", + "description": "DESCRIPTION1", + "objects": [], + "self": "/v2/metadefs/namespaces/%s" % NAMESPACE1, + "namespace": NAMESPACE6, + "visibility": "public", + "protected": True, + "owner": "admin", + "resource_types": [ + { + "name": RESOURCE_TYPE1 + } + ], + "schema": "/v2/schemas/metadefs/namespace", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + ), + }, +} + +schema_fixtures = { + "metadefs/namespace": + { + "GET": ( + {}, + { + "additionalProperties": False, + "definitions": { + "property": { + "additionalProperties": { + "required": [ + "title", + "type" + ], + "type": "object", + "properties": { + "additionalItems": { + "type": "boolean" + }, + "enum": { + "type": "array" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "default": {}, + "minLength": { + "$ref": "#/definitions/" + "positiveIntegerDefault0" + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "maximum": { + "type": "number" + }, + "minItems": { + "$ref": "#/definitions/" + "positiveIntegerDefault0" + }, + "readonly": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "maxItems": { + "$ref": "#/definitions/" + "positiveInteger" + }, + "maxLength": { + "$ref": "#/definitions/positiveInteger" + }, + "uniqueItems": { + "default": False, + "type": "boolean" + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "items": { + "type": "object", + "properties": { + "enum": { + "type": "array" + }, + "type": { + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + "null" + ], + "type": "string" + } + } + }, + "type": { + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + "null" + ], + "type": "string" + } + } + }, + "type": "object" + }, + "positiveIntegerDefault0": { + "allOf": [ + { + "$ref": "#/definitions/positiveInteger" + }, + { + "default": 0 + } + ] + }, + "stringArray": { + "uniqueItems": True, + "items": { + "type": "string" + }, + "type": "array" + }, + "positiveInteger": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "namespace" + ], + "name": "namespace", + "properties": { + "description": { + "type": "string", + "description": "Provides a user friendly description " + "of the namespace.", + "maxLength": 500 + }, + "updated_at": { + "type": "string", + "description": "Date and time of the last namespace " + "modification (READ-ONLY)", + "format": "date-time" + }, + "visibility": { + "enum": [ + "public", + "private" + ], + "type": "string", + "description": "Scope of namespace accessibility." + }, + "self": { + "type": "string" + }, + "objects": { + "items": { + "type": "object", + "properties": { + "properties": { + "$ref": "#/definitions/property" + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "type": "array" + }, + "owner": { + "type": "string", + "description": "Owner of the namespace.", + "maxLength": 255 + }, + "resource_types": { + "items": { + "type": "object", + "properties": { + "prefix": { + "type": "string" + }, + "name": { + "type": "string" + }, + "metadata_type": { + "type": "string" + } + } + }, + "type": "array" + }, + "properties": { + "$ref": "#/definitions/property" + }, + "display_name": { + "type": "string", + "description": "The user friendly name for the " + "namespace. Used by UI if available.", + "maxLength": 80 + }, + "created_at": { + "type": "string", + "description": "Date and time of namespace creation " + "(READ-ONLY)", + "format": "date-time" + }, + "namespace": { + "type": "string", + "description": "The unique namespace text.", + "maxLength": 80 + }, + "protected": { + "type": "boolean", + "description": "If true, namespace will not be " + "deletable." + }, + "schema": { + "type": "string" + } + } + } + ), + } +} + + +class TestNamespaceController(testtools.TestCase): + def setUp(self): + super(TestNamespaceController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = metadefs.NamespaceController(self.api, + self.schema_api) + + def test_list_namespaces(self): + namespaces = list(self.controller.list()) + + self.assertEqual(2, len(namespaces)) + self.assertEqual(NAMESPACE1, namespaces[0]['namespace']) + self.assertEqual(NAMESPACE2, namespaces[1]['namespace']) + + def test_list_namespaces_paginate(self): + namespaces = list(self.controller.list(page_size=1)) + + self.assertEqual(2, len(namespaces)) + self.assertEqual(NAMESPACE7, namespaces[0]['namespace']) + self.assertEqual(NAMESPACE8, namespaces[1]['namespace']) + + def test_list_with_limit_greater_than_page_size(self): + namespaces = list(self.controller.list(page_size=1, limit=2)) + self.assertEqual(2, len(namespaces)) + self.assertEqual(NAMESPACE7, namespaces[0]['namespace']) + self.assertEqual(NAMESPACE8, namespaces[1]['namespace']) + + def test_list_with_marker(self): + namespaces = list(self.controller.list(marker=NAMESPACE6, page_size=2)) + self.assertEqual(2, len(namespaces)) + self.assertEqual(NAMESPACE7, namespaces[0]['namespace']) + self.assertEqual(NAMESPACE8, namespaces[1]['namespace']) + + def test_list_with_sort_dir(self): + namespaces = list(self.controller.list(sort_dir='asc', limit=1)) + self.assertEqual(1, len(namespaces)) + self.assertEqual(NAMESPACE1, namespaces[0]['namespace']) + + def test_list_with_sort_dir_invalid(self): + # NOTE(TravT): The clients work by returning an iterator. + # Invoking the iterator is what actually executes the logic. + ns_iterator = self.controller.list(sort_dir='foo') + self.assertRaises(ValueError, next, ns_iterator) + + def test_list_with_sort_key(self): + namespaces = list(self.controller.list(sort_key='created_at', limit=1)) + self.assertEqual(1, len(namespaces)) + self.assertEqual(NAMESPACE1, namespaces[0]['namespace']) + + def test_list_with_sort_key_invalid(self): + # NOTE(TravT): The clients work by returning an iterator. + # Invoking the iterator is what actually executes the logic. + ns_iterator = self.controller.list(sort_key='foo') + self.assertRaises(ValueError, next, ns_iterator) + + def test_list_namespaces_with_one_resource_type_filter(self): + namespaces = list(self.controller.list( + filters={ + 'resource_types': [RESOURCE_TYPE1] + } + )) + + self.assertEqual(1, len(namespaces)) + self.assertEqual(NAMESPACE3, namespaces[0]['namespace']) + + def test_list_namespaces_with_multiple_resource_types_filter(self): + namespaces = list(self.controller.list( + filters={ + 'resource_types': [RESOURCE_TYPE1, RESOURCE_TYPE2] + } + )) + + self.assertEqual(1, len(namespaces)) + self.assertEqual(NAMESPACE4, namespaces[0]['namespace']) + + def test_list_namespaces_with_visibility_filter(self): + namespaces = list(self.controller.list( + filters={ + 'visibility': 'private' + } + )) + + self.assertEqual(1, len(namespaces)) + self.assertEqual(NAMESPACE5, namespaces[0]['namespace']) + + def test_get_namespace(self): + namespace = self.controller.get(NAMESPACE1) + self.assertEqual(NAMESPACE1, namespace.namespace) + self.assertTrue(namespace.protected) + + def test_get_namespace_with_resource_type(self): + namespace = self.controller.get(NAMESPACE6, + resource_type=RESOURCE_TYPE1) + self.assertEqual(NAMESPACE6, namespace.namespace) + self.assertTrue(namespace.protected) + + def test_create_namespace(self): + properties = { + 'namespace': NAMESPACENEW + } + namespace = self.controller.create(**properties) + + self.assertEqual(NAMESPACENEW, namespace.namespace) + self.assertTrue(namespace.protected) + + def test_create_namespace_invalid_data(self): + properties = {} + + self.assertRaises(TypeError, self.controller.create, **properties) + + def test_create_namespace_invalid_property(self): + properties = {'namespace': 'NewNamespace', 'protected': '123'} + + self.assertRaises(TypeError, self.controller.create, **properties) + + def test_update_namespace(self): + properties = {'display_name': 'My Updated Name'} + namespace = self.controller.update(NAMESPACE1, **properties) + + self.assertEqual(NAMESPACE1, namespace.namespace) + + def test_update_namespace_invalid_property(self): + properties = {'protected': '123'} + + self.assertRaises(TypeError, self.controller.update, NAMESPACE1, + **properties) + + def test_delete_namespace(self): + self.controller.delete(NAMESPACE1) + expect = [ + ('DELETE', + '/v2/metadefs/namespaces/%s' % NAMESPACE1, + {}, + None)] + self.assertEqual(expect, self.api.calls) diff --git a/glanceclient/tests/unit/v2/test_metadefs_objects.py b/glanceclient/tests/unit/v2/test_metadefs_objects.py new file mode 100644 index 0000000..c565b3c --- /dev/null +++ b/glanceclient/tests/unit/v2/test_metadefs_objects.py @@ -0,0 +1,323 @@ +# 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 six +import testtools + +from glanceclient.tests import utils +from glanceclient.v2 import metadefs + +NAMESPACE1 = 'Namespace1' +OBJECT1 = 'Object1' +OBJECT2 = 'Object2' +OBJECTNEW = 'ObjectNew' +PROPERTY1 = 'Property1' +PROPERTY2 = 'Property2' +PROPERTY3 = 'Property3' +PROPERTY4 = 'Property4' + + +def _get_object_fixture(ns_name, obj_name, **kwargs): + obj = { + "description": "DESCRIPTION", + "name": obj_name, + "self": "/v2/metadefs/namespaces/%s/objects/%s" % + (ns_name, obj_name), + "required": [], + "properties": { + PROPERTY1: { + "type": "integer", + "description": "DESCRIPTION", + "title": "Quota: CPU Shares" + }, + PROPERTY2: { + "minimum": 1000, + "type": "integer", + "description": "DESCRIPTION", + "maximum": 1000000, + "title": "Quota: CPU Period" + }}, + "schema": "/v2/schemas/metadefs/object", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + + obj.update(kwargs) + + return obj + +data_fixtures = { + "/v2/metadefs/namespaces/%s/objects" % NAMESPACE1: { + "GET": ( + {}, + { + "objects": [ + _get_object_fixture(NAMESPACE1, OBJECT1), + _get_object_fixture(NAMESPACE1, OBJECT2) + ], + "schema": "v2/schemas/metadefs/objects" + } + ), + "POST": ( + {}, + _get_object_fixture(NAMESPACE1, OBJECTNEW) + ), + "DELETE": ( + {}, + {} + ) + }, + "/v2/metadefs/namespaces/%s/objects/%s" % (NAMESPACE1, OBJECT1): { + "GET": ( + {}, + _get_object_fixture(NAMESPACE1, OBJECT1) + ), + "PUT": ( + {}, + _get_object_fixture(NAMESPACE1, OBJECT1) + ), + "DELETE": ( + {}, + {} + ) + } +} + +schema_fixtures = { + "metadefs/object": { + "GET": ( + {}, + { + "additionalProperties": False, + "definitions": { + "property": { + "additionalProperties": { + "required": [ + "title", + "type" + ], + "type": "object", + "properties": { + "additionalItems": { + "type": "boolean" + }, + "enum": { + "type": "array" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "default": {}, + "minLength": { + "$ref": "#/definitions/positiveInteger" + "Default0" + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "maximum": { + "type": "number" + }, + "minItems": { + "$ref": "#/definitions/positiveInteger" + "Default0" + }, + "readonly": { + "type": "boolean" + }, + "minimum": { + "type": "number" + }, + "maxItems": { + "$ref": "#/definitions/positiveInteger" + }, + "maxLength": { + "$ref": "#/definitions/positiveInteger" + }, + "uniqueItems": { + "default": False, + "type": "boolean" + }, + "pattern": { + "type": "string", + "format": "regex" + }, + "items": { + "type": "object", + "properties": { + "enum": { + "type": "array" + }, + "type": { + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + "null" + ], + "type": "string" + } + } + }, + "type": { + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + "null" + ], + "type": "string" + } + } + }, + "type": "object" + }, + "positiveIntegerDefault0": { + "allOf": [ + { + "$ref": "#/definitions/positiveInteger" + }, + { + "default": 0 + } + ] + }, + "stringArray": { + "uniqueItems": True, + "items": { + "type": "string" + }, + "type": "array" + }, + "positiveInteger": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name" + ], + "name": "object", + "properties": { + "created_at": { + "type": "string", + "description": "Date and time of object creation " + "(READ-ONLY)", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "self": { + "type": "string" + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "properties": { + "$ref": "#/definitions/property" + }, + "schema": { + "type": "string" + }, + "updated_at": { + "type": "string", + "description": "Date and time of the last object " + "modification (READ-ONLY)", + "format": "date-time" + }, + } + } + ) + } +} + + +class TestObjectController(testtools.TestCase): + def setUp(self): + super(TestObjectController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = metadefs.ObjectController(self.api, self.schema_api) + + def test_list_object(self): + objects = list(self.controller.list(NAMESPACE1)) + + actual = [obj.name for obj in objects] + self.assertEqual([OBJECT1, OBJECT2], actual) + + def test_get_object(self): + obj = self.controller.get(NAMESPACE1, OBJECT1) + self.assertEqual(OBJECT1, obj.name) + self.assertEqual(sorted([PROPERTY1, PROPERTY2]), + sorted(list(six.iterkeys(obj.properties)))) + + def test_create_object(self): + properties = { + 'name': OBJECTNEW, + 'description': 'DESCRIPTION' + } + obj = self.controller.create(NAMESPACE1, **properties) + self.assertEqual(OBJECTNEW, obj.name) + + def test_create_object_invalid_property(self): + properties = { + 'namespace': NAMESPACE1 + } + self.assertRaises(TypeError, self.controller.create, **properties) + + def test_update_object(self): + properties = { + 'description': 'UPDATED_DESCRIPTION' + } + obj = self.controller.update(NAMESPACE1, OBJECT1, **properties) + self.assertEqual(OBJECT1, obj.name) + + def test_update_object_invalid_property(self): + properties = { + 'required': 'INVALID' + } + self.assertRaises(TypeError, self.controller.update, NAMESPACE1, + OBJECT1, **properties) + + def test_delete_object(self): + self.controller.delete(NAMESPACE1, OBJECT1) + expect = [ + ('DELETE', + '/v2/metadefs/namespaces/%s/objects/%s' % (NAMESPACE1, OBJECT1), + {}, + None)] + self.assertEqual(expect, self.api.calls) + + def test_delete_all_objects(self): + self.controller.delete_all(NAMESPACE1) + expect = [ + ('DELETE', + '/v2/metadefs/namespaces/%s/objects' % NAMESPACE1, + {}, + None)] + self.assertEqual(expect, self.api.calls) diff --git a/glanceclient/tests/unit/v2/test_metadefs_properties.py b/glanceclient/tests/unit/v2/test_metadefs_properties.py new file mode 100644 index 0000000..388bf93 --- /dev/null +++ b/glanceclient/tests/unit/v2/test_metadefs_properties.py @@ -0,0 +1,300 @@ +# 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 testtools + +from glanceclient.tests import utils +from glanceclient.v2 import metadefs + +NAMESPACE1 = 'Namespace1' +PROPERTY1 = 'Property1' +PROPERTY2 = 'Property2' +PROPERTYNEW = 'PropertyNew' + +data_fixtures = { + "/v2/metadefs/namespaces/%s/properties" % NAMESPACE1: { + "GET": ( + {}, + { + "properties": { + PROPERTY1: { + "default": "1", + "type": "integer", + "description": "Number of cores.", + "title": "cores" + }, + PROPERTY2: { + "items": { + "enum": [ + "Intel", + "AMD" + ], + "type": "string" + }, + "type": "array", + "description": "Specifies the CPU manufacturer.", + "title": "Vendor" + }, + } + } + ), + "POST": ( + {}, + { + "items": { + "enum": [ + "Intel", + "AMD" + ], + "type": "string" + }, + "type": "array", + "description": "UPDATED_DESCRIPTION", + "title": "Vendor", + "name": PROPERTYNEW + } + ), + "DELETE": ( + {}, + {} + ) + }, + "/v2/metadefs/namespaces/%s/properties/%s" % (NAMESPACE1, PROPERTY1): { + "GET": ( + {}, + { + "items": { + "enum": [ + "Intel", + "AMD" + ], + "type": "string" + }, + "type": "array", + "description": "Specifies the CPU manufacturer.", + "title": "Vendor" + } + ), + "PUT": ( + {}, + { + "items": { + "enum": [ + "Intel", + "AMD" + ], + "type": "string" + }, + "type": "array", + "description": "UPDATED_DESCRIPTION", + "title": "Vendor" + } + ), + "DELETE": ( + {}, + {} + ) + } +} + +schema_fixtures = { + "metadefs/property": { + "GET": ( + {}, + { + "additionalProperties": False, + "definitions": { + "positiveIntegerDefault0": { + "allOf": [ + { + "$ref": "#/definitions/positiveInteger" + }, + { + "default": 0 + } + ] + }, + "stringArray": { + "minItems": 1, + "items": { + "type": "string" + }, + "uniqueItems": True, + "type": "array" + }, + "positiveInteger": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "title", + "type" + ], + "name": "property", + "properties": { + "description": { + "type": "string" + }, + "minLength": { + "$ref": "#/definitions/positiveIntegerDefault0" + }, + "enum": { + "type": "array" + }, + "minimum": { + "type": "number" + }, + "maxItems": { + "$ref": "#/definitions/positiveInteger" + }, + "maxLength": { + "$ref": "#/definitions/positiveInteger" + }, + "uniqueItems": { + "default": False, + "type": "boolean" + }, + "additionalItems": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "default": {}, + "pattern": { + "type": "string", + "format": "regex" + }, + "required": { + "$ref": "#/definitions/stringArray" + }, + "maximum": { + "type": "number" + }, + "minItems": { + "$ref": "#/definitions/positiveIntegerDefault0" + }, + "readonly": { + "type": "boolean" + }, + "items": { + "type": "object", + "properties": { + "enum": { + "type": "array" + }, + "type": { + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + "null" + ], + "type": "string" + } + } + }, + "type": { + "enum": [ + "array", + "boolean", + "integer", + "number", + "object", + "string", + "null" + ], + "type": "string" + } + } + } + ) + } +} + + +class TestPropertyController(testtools.TestCase): + def setUp(self): + super(TestPropertyController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = metadefs.PropertyController(self.api, + self.schema_api) + + def test_list_property(self): + properties = list(self.controller.list(NAMESPACE1)) + + actual = [prop.name for prop in properties] + self.assertEqual(sorted([PROPERTY1, PROPERTY2]), sorted(actual)) + + def test_get_property(self): + prop = self.controller.get(NAMESPACE1, PROPERTY1) + self.assertEqual(PROPERTY1, prop.name) + + def test_create_property(self): + properties = { + 'name': PROPERTYNEW, + 'title': 'TITLE', + 'type': 'string' + } + obj = self.controller.create(NAMESPACE1, **properties) + self.assertEqual(PROPERTYNEW, obj.name) + + def test_create_property_invalid_property(self): + properties = { + 'namespace': NAMESPACE1 + } + self.assertRaises(TypeError, self.controller.create, **properties) + + def test_update_property(self): + properties = { + 'description': 'UPDATED_DESCRIPTION' + } + prop = self.controller.update(NAMESPACE1, PROPERTY1, **properties) + self.assertEqual(PROPERTY1, prop.name) + + def test_update_property_invalid_property(self): + properties = { + 'type': 'INVALID' + } + self.assertRaises(TypeError, self.controller.update, NAMESPACE1, + PROPERTY1, **properties) + + def test_delete_property(self): + self.controller.delete(NAMESPACE1, PROPERTY1) + expect = [ + ('DELETE', + '/v2/metadefs/namespaces/%s/properties/%s' % (NAMESPACE1, + PROPERTY1), + {}, + None)] + self.assertEqual(expect, self.api.calls) + + def test_delete_all_properties(self): + self.controller.delete_all(NAMESPACE1) + expect = [ + ('DELETE', + '/v2/metadefs/namespaces/%s/properties' % NAMESPACE1, + {}, + None)] + self.assertEqual(expect, self.api.calls) diff --git a/glanceclient/tests/unit/v2/test_metadefs_resource_types.py b/glanceclient/tests/unit/v2/test_metadefs_resource_types.py new file mode 100644 index 0000000..de3f9c2 --- /dev/null +++ b/glanceclient/tests/unit/v2/test_metadefs_resource_types.py @@ -0,0 +1,186 @@ +# 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 testtools + +from glanceclient.tests import utils +from glanceclient.v2 import metadefs + +NAMESPACE1 = 'Namespace1' +RESOURCE_TYPE1 = 'ResourceType1' +RESOURCE_TYPE2 = 'ResourceType2' +RESOURCE_TYPE3 = 'ResourceType3' +RESOURCE_TYPE4 = 'ResourceType4' +RESOURCE_TYPENEW = 'ResourceTypeNew' + + +data_fixtures = { + "/v2/metadefs/namespaces/%s/resource_types" % NAMESPACE1: { + "GET": ( + {}, + { + "resource_type_associations": [ + { + "name": RESOURCE_TYPE3, + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + }, + { + "name": RESOURCE_TYPE4, + "prefix": "PREFIX:", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + ] + } + ), + "POST": ( + {}, + { + "name": RESOURCE_TYPENEW, + "prefix": "PREFIX:", + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + ), + }, + "/v2/metadefs/namespaces/%s/resource_types/%s" % (NAMESPACE1, + RESOURCE_TYPE1): + { + "DELETE": ( + {}, + {} + ), + }, + "/v2/metadefs/resource_types": { + "GET": ( + {}, + { + "resource_types": [ + { + "name": RESOURCE_TYPE1, + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + }, + { + "name": RESOURCE_TYPE2, + "created_at": "2014-08-14T09:07:06Z", + "updated_at": "2014-08-14T09:07:06Z", + } + ] + } + ) + } +} + +schema_fixtures = { + "metadefs/resource_type": { + "GET": ( + {}, + { + "name": "resource_type", + "properties": { + "prefix": { + "type": "string", + "description": "Specifies the prefix to use for the " + "given resource type. Any properties " + "in the namespace should be prefixed " + "with this prefix when being applied " + "to the specified resource type. Must " + "include prefix separator (e.g. a " + "colon :).", + "maxLength": 80 + }, + "properties_target": { + "type": "string", + "description": "Some resource types allow more than " + "one key / value pair per instance. " + "For example, Cinder allows user and " + "image metadata on volumes. Only the " + "image properties metadata is " + "evaluated by Nova (scheduling or " + "drivers). This property allows a " + "namespace target to remove the " + "ambiguity.", + "maxLength": 80 + }, + "name": { + "type": "string", + "description": "Resource type names should be " + "aligned with Heat resource types " + "whenever possible: http://docs." + "openstack.org/developer/heat/" + "template_guide/openstack.html", + "maxLength": 80 + }, + "created_at": { + "type": "string", + "description": "Date and time of resource type " + "association (READ-ONLY)", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "description": "Date and time of the last resource " + "type association modification " + "(READ-ONLY)", + "format": "date-time" + }, + } + } + ) + } +} + + +class TestResoureTypeController(testtools.TestCase): + def setUp(self): + super(TestResoureTypeController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = metadefs.ResourceTypeController(self.api, + self.schema_api) + + def test_list_resource_types(self): + resource_types = list(self.controller.list()) + names = [rt.name for rt in resource_types] + self.assertEqual([RESOURCE_TYPE1, RESOURCE_TYPE2], names) + + def test_get_resource_types(self): + resource_types = list(self.controller.get(NAMESPACE1)) + names = [rt.name for rt in resource_types] + self.assertEqual([RESOURCE_TYPE3, RESOURCE_TYPE4], names) + + def test_associate_resource_types(self): + resource_types = self.controller.associate(NAMESPACE1, + name=RESOURCE_TYPENEW) + + self.assertEqual(RESOURCE_TYPENEW, resource_types['name']) + + def test_associate_resource_types_invalid_property(self): + longer = '1234' * 50 + properties = {'name': RESOURCE_TYPENEW, 'prefix': longer} + self.assertRaises(TypeError, self.controller.associate, NAMESPACE1, + **properties) + + def test_deassociate_resource_types(self): + self.controller.deassociate(NAMESPACE1, RESOURCE_TYPE1) + expect = [ + ('DELETE', + '/v2/metadefs/namespaces/%s/resource_types/%s' % (NAMESPACE1, + RESOURCE_TYPE1), + {}, + None)] + self.assertEqual(expect, self.api.calls) diff --git a/glanceclient/tests/unit/v2/test_schemas.py b/glanceclient/tests/unit/v2/test_schemas.py new file mode 100644 index 0000000..35788cd --- /dev/null +++ b/glanceclient/tests/unit/v2/test_schemas.py @@ -0,0 +1,231 @@ +# 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. + +from jsonpatch import JsonPatch +import testtools +import warlock + +from glanceclient.tests import utils +from glanceclient.v2 import schemas + + +fixtures = { + '/v2/schemas': { + 'GET': ( + {}, + { + 'image': '/v2/schemas/image', + 'access': '/v2/schemas/image/access', + }, + ), + }, + '/v2/schemas/image': { + 'GET': ( + {}, + { + 'name': 'image', + 'properties': { + 'name': {'type': 'string', + 'description': 'Name of image'}, + 'tags': {'type': 'array'} + }, + + }, + ), + }, +} + + +_SCHEMA = schemas.Schema({ + 'name': 'image', + 'properties': { + 'name': {'type': 'string'}, + 'color': {'type': 'string'}, + 'shape': {'type': 'string', 'is_base': False}, + 'tags': {'type': 'array'} + }, +}) + + +def compare_json_patches(a, b): + """Return 0 if a and b describe the same JSON patch.""" + return JsonPatch.from_string(a) == JsonPatch.from_string(b) + + +class TestSchemaProperty(testtools.TestCase): + def test_property_minimum(self): + prop = schemas.SchemaProperty('size') + self.assertEqual('size', prop.name) + + def test_property_description(self): + prop = schemas.SchemaProperty('size', description='some quantity') + self.assertEqual('size', prop.name) + self.assertEqual('some quantity', prop.description) + + def test_property_is_base(self): + prop1 = schemas.SchemaProperty('name') + prop2 = schemas.SchemaProperty('foo', is_base=False) + prop3 = schemas.SchemaProperty('foo', is_base=True) + self.assertTrue(prop1.is_base) + self.assertFalse(prop2.is_base) + self.assertTrue(prop3.is_base) + + +class TestSchema(testtools.TestCase): + def test_schema_minimum(self): + raw_schema = {'name': 'Country', 'properties': {}} + schema = schemas.Schema(raw_schema) + self.assertEqual('Country', schema.name) + self.assertEqual([], schema.properties) + + def test_schema_with_property(self): + raw_schema = {'name': 'Country', 'properties': {'size': {}}} + schema = schemas.Schema(raw_schema) + self.assertEqual('Country', schema.name) + self.assertEqual(['size'], [p.name for p in schema.properties]) + + def test_raw(self): + raw_schema = {'name': 'Country', 'properties': {}} + schema = schemas.Schema(raw_schema) + self.assertEqual(raw_schema, schema.raw()) + + def test_property_is_base(self): + raw_schema = {'name': 'Country', + 'properties': { + 'size': {}, + 'population': {'is_base': False}}} + schema = schemas.Schema(raw_schema) + self.assertTrue(schema.is_base_property('size')) + self.assertFalse(schema.is_base_property('population')) + self.assertFalse(schema.is_base_property('foo')) + + +class TestController(testtools.TestCase): + def setUp(self): + super(TestController, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.controller = schemas.Controller(self.api) + + def test_get_schema(self): + schema = self.controller.get('image') + self.assertEqual('image', schema.name) + self.assertEqual(['name', 'tags'], + [p.name for p in schema.properties]) + + +class TestSchemaBasedModel(testtools.TestCase): + def setUp(self): + super(TestSchemaBasedModel, self).setUp() + self.model = warlock.model_factory(_SCHEMA.raw(), + schemas.SchemaBasedModel) + + def test_patch_should_replace_missing_core_properties(self): + obj = { + 'name': 'fred' + } + + original = self.model(obj) + original['color'] = 'red' + + patch = original.patch + expected = '[{"path": "/color", "value": "red", "op": "replace"}]' + self.assertTrue(compare_json_patches(patch, expected)) + + def test_patch_should_add_extra_properties(self): + obj = { + 'name': 'fred', + } + + original = self.model(obj) + original['weight'] = '10' + + patch = original.patch + expected = '[{"path": "/weight", "value": "10", "op": "add"}]' + self.assertTrue(compare_json_patches(patch, expected)) + + def test_patch_should_replace_extra_properties(self): + obj = { + 'name': 'fred', + 'weight': '10' + } + + original = self.model(obj) + original['weight'] = '22' + + patch = original.patch + expected = '[{"path": "/weight", "value": "22", "op": "replace"}]' + self.assertTrue(compare_json_patches(patch, expected)) + + def test_patch_should_remove_extra_properties(self): + obj = { + 'name': 'fred', + 'weight': '10' + } + + original = self.model(obj) + del original['weight'] + + patch = original.patch + expected = '[{"path": "/weight", "op": "remove"}]' + self.assertTrue(compare_json_patches(patch, expected)) + + def test_patch_should_remove_core_properties(self): + obj = { + 'name': 'fred', + 'color': 'red' + } + + original = self.model(obj) + del original['color'] + + patch = original.patch + expected = '[{"path": "/color", "op": "remove"}]' + self.assertTrue(compare_json_patches(patch, expected)) + + def test_patch_should_add_missing_custom_properties(self): + obj = { + 'name': 'fred' + } + + original = self.model(obj) + original['shape'] = 'circle' + + patch = original.patch + expected = '[{"path": "/shape", "value": "circle", "op": "add"}]' + self.assertTrue(compare_json_patches(patch, expected)) + + def test_patch_should_replace_custom_properties(self): + obj = { + 'name': 'fred', + 'shape': 'circle' + } + + original = self.model(obj) + original['shape'] = 'square' + + patch = original.patch + expected = '[{"path": "/shape", "value": "square", "op": "replace"}]' + self.assertTrue(compare_json_patches(patch, expected)) + + def test_patch_should_replace_tags(self): + obj = {'name': 'fred', } + + original = self.model(obj) + original['tags'] = ['tag1', 'tag2'] + + patch = original.patch + expected = '[{"path": "/tags", "value": ["tag1", "tag2"], ' \ + '"op": "replace"}]' + self.assertTrue(compare_json_patches(patch, expected)) diff --git a/glanceclient/tests/unit/v2/test_shell_v2.py b/glanceclient/tests/unit/v2/test_shell_v2.py new file mode 100644 index 0000000..33985b9 --- /dev/null +++ b/glanceclient/tests/unit/v2/test_shell_v2.py @@ -0,0 +1,1094 @@ +# Copyright 2013 OpenStack Foundation +# Copyright (C) 2013 Yahoo! 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 json +import mock +import os +import tempfile +import testtools + +from glanceclient.common import utils +from glanceclient.v2 import shell as test_shell + + +class ShellV2Test(testtools.TestCase): + def setUp(self): + super(ShellV2Test, self).setUp() + self._mock_utils() + self.gc = self._mock_glance_client() + + def _make_args(self, args): + #NOTE(venkatesh): this conversion from a dict to an object + # is required because the test_shell.do_xxx(gc, args) methods + # expects the args to be attributes of an object. If passed as + # dict directly, it throws an AttributeError. + class Args(): + def __init__(self, entries): + self.__dict__.update(entries) + + return Args(args) + + def _mock_glance_client(self): + my_mocked_gc = mock.Mock() + my_mocked_gc.schemas.return_value = 'test' + my_mocked_gc.get.return_value = {} + return my_mocked_gc + + def _mock_utils(self): + utils.print_list = mock.Mock() + utils.print_dict = mock.Mock() + utils.save_image = mock.Mock() + + def assert_exits_with_msg(self, func, func_args, err_msg): + with mock.patch.object(utils, 'exit') as mocked_utils_exit: + mocked_utils_exit.return_value = '%s' % err_msg + + func(self.gc, func_args) + + mocked_utils_exit.assert_called_once_with(err_msg) + + def test_do_image_list(self): + input = { + 'limit': None, + 'page_size': 18, + 'visibility': True, + 'member_status': 'Fake', + 'owner': 'test', + 'checksum': 'fake_checksum', + 'tag': 'fake tag', + 'properties': [], + 'sort_key': ['name', 'id'], + 'sort_dir': ['desc', 'asc'], + 'sort': None + } + args = self._make_args(input) + with mock.patch.object(self.gc.images, 'list') as mocked_list: + mocked_list.return_value = {} + + test_shell.do_image_list(self.gc, args) + + exp_img_filters = { + 'owner': 'test', + 'member_status': 'Fake', + 'visibility': True, + 'checksum': 'fake_checksum', + 'tag': 'fake tag' + } + mocked_list.assert_called_once_with(page_size=18, + sort_key=['name', 'id'], + sort_dir=['desc', 'asc'], + filters=exp_img_filters) + utils.print_list.assert_called_once_with({}, ['ID', 'Name']) + + def test_do_image_list_with_single_sort_key(self): + input = { + 'limit': None, + 'page_size': 18, + 'visibility': True, + 'member_status': 'Fake', + 'owner': 'test', + 'checksum': 'fake_checksum', + 'tag': 'fake tag', + 'properties': [], + 'sort_key': ['name'], + 'sort_dir': ['desc'], + 'sort': None + } + args = self._make_args(input) + with mock.patch.object(self.gc.images, 'list') as mocked_list: + mocked_list.return_value = {} + + test_shell.do_image_list(self.gc, args) + + exp_img_filters = { + 'owner': 'test', + 'member_status': 'Fake', + 'visibility': True, + 'checksum': 'fake_checksum', + 'tag': 'fake tag' + } + mocked_list.assert_called_once_with(page_size=18, + sort_key=['name'], + sort_dir=['desc'], + filters=exp_img_filters) + utils.print_list.assert_called_once_with({}, ['ID', 'Name']) + + def test_do_image_list_new_sorting_syntax(self): + input = { + 'limit': None, + 'page_size': 18, + 'visibility': True, + 'member_status': 'Fake', + 'owner': 'test', + 'checksum': 'fake_checksum', + 'tag': 'fake tag', + 'properties': [], + 'sort': 'name:desc,size:asc', + 'sort_key': [], + 'sort_dir': [] + } + args = self._make_args(input) + with mock.patch.object(self.gc.images, 'list') as mocked_list: + mocked_list.return_value = {} + + test_shell.do_image_list(self.gc, args) + + exp_img_filters = { + 'owner': 'test', + 'member_status': 'Fake', + 'visibility': True, + 'checksum': 'fake_checksum', + 'tag': 'fake tag' + } + mocked_list.assert_called_once_with( + page_size=18, + sort='name:desc,size:asc', + filters=exp_img_filters) + utils.print_list.assert_called_once_with({}, ['ID', 'Name']) + + def test_do_image_list_with_property_filter(self): + input = { + 'limit': None, + 'page_size': 1, + 'visibility': True, + 'member_status': 'Fake', + 'owner': 'test', + 'checksum': 'fake_checksum', + 'tag': 'fake tag', + 'properties': ['os_distro=NixOS', 'architecture=x86_64'], + 'sort_key': ['name'], + 'sort_dir': ['desc'], + 'sort': None + } + args = self._make_args(input) + with mock.patch.object(self.gc.images, 'list') as mocked_list: + mocked_list.return_value = {} + + test_shell.do_image_list(self.gc, args) + + exp_img_filters = { + 'owner': 'test', + 'member_status': 'Fake', + 'visibility': True, + 'checksum': 'fake_checksum', + 'tag': 'fake tag', + 'os_distro': 'NixOS', + 'architecture': 'x86_64' + } + + mocked_list.assert_called_once_with(page_size=1, + sort_key=['name'], + sort_dir=['desc'], + filters=exp_img_filters) + utils.print_list.assert_called_once_with({}, ['ID', 'Name']) + + def test_do_image_show_human_readable(self): + args = self._make_args({'id': 'pass', 'page_size': 18, + 'human_readable': True, + 'max_column_width': 120}) + with mock.patch.object(self.gc.images, 'get') as mocked_list: + ignore_fields = ['self', 'access', 'file', 'schema'] + expect_image = dict([(field, field) for field in ignore_fields]) + expect_image['id'] = 'pass' + expect_image['size'] = 1024 + mocked_list.return_value = expect_image + + test_shell.do_image_show(self.gc, args) + + mocked_list.assert_called_once_with('pass') + utils.print_dict.assert_called_once_with({'id': 'pass', + 'size': '1kB'}, + max_column_width=120) + + def test_do_image_show(self): + args = self._make_args({'id': 'pass', 'page_size': 18, + 'human_readable': False, + 'max_column_width': 120}) + with mock.patch.object(self.gc.images, 'get') as mocked_list: + ignore_fields = ['self', 'access', 'file', 'schema'] + expect_image = dict([(field, field) for field in ignore_fields]) + expect_image['id'] = 'pass' + expect_image['size'] = 1024 + mocked_list.return_value = expect_image + + test_shell.do_image_show(self.gc, args) + + mocked_list.assert_called_once_with('pass') + utils.print_dict.assert_called_once_with({'id': 'pass', + 'size': 1024}, + max_column_width=120) + + @mock.patch('sys.stdin', autospec=True) + def test_do_image_create_no_user_props(self, mock_stdin): + args = self._make_args({'name': 'IMG-01', 'disk_format': 'vhd', + 'container_format': 'bare', + 'file': None}) + with mock.patch.object(self.gc.images, 'create') as mocked_create: + ignore_fields = ['self', 'access', 'file', 'schema'] + expect_image = dict([(field, field) for field in ignore_fields]) + expect_image['id'] = 'pass' + expect_image['name'] = 'IMG-01' + expect_image['disk_format'] = 'vhd' + expect_image['container_format'] = 'bare' + mocked_create.return_value = expect_image + + # Ensure that the test stdin is not considered + # to be supplying image data + mock_stdin.isatty = lambda: True + test_shell.do_image_create(self.gc, args) + + mocked_create.assert_called_once_with(name='IMG-01', + disk_format='vhd', + container_format='bare') + utils.print_dict.assert_called_once_with({ + 'id': 'pass', 'name': 'IMG-01', 'disk_format': 'vhd', + 'container_format': 'bare'}) + + def test_do_image_create_with_file(self): + try: + file_name = None + with open(tempfile.mktemp(), 'w+') as f: + f.write('Some data here') + f.flush() + f.seek(0) + file_name = f.name + temp_args = {'name': 'IMG-01', + 'disk_format': 'vhd', + 'container_format': 'bare', + 'file': file_name, + 'progress': False} + args = self._make_args(temp_args) + with mock.patch.object(self.gc.images, 'create') as mocked_create: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + + ignore_fields = ['self', 'access', 'schema'] + expect_image = dict([(field, field) for field in + ignore_fields]) + expect_image['id'] = 'pass' + expect_image['name'] = 'IMG-01' + expect_image['disk_format'] = 'vhd' + expect_image['container_format'] = 'bare' + mocked_create.return_value = expect_image + mocked_get.return_value = expect_image + + test_shell.do_image_create(self.gc, args) + + temp_args.pop('file', None) + mocked_create.assert_called_once_with(**temp_args) + mocked_get.assert_called_once_with('pass') + utils.print_dict.assert_called_once_with({ + 'id': 'pass', 'name': 'IMG-01', 'disk_format': 'vhd', + 'container_format': 'bare'}) + finally: + try: + os.remove(f.name) + except Exception: + pass + + @mock.patch('sys.stdin', autospec=True) + def test_do_image_create_with_user_props(self, mock_stdin): + args = self._make_args({'name': 'IMG-01', + 'property': ['myprop=myval'], + 'file': None}) + with mock.patch.object(self.gc.images, 'create') as mocked_create: + ignore_fields = ['self', 'access', 'file', 'schema'] + expect_image = dict([(field, field) for field in ignore_fields]) + expect_image['id'] = 'pass' + expect_image['name'] = 'IMG-01' + expect_image['myprop'] = 'myval' + mocked_create.return_value = expect_image + + # Ensure that the test stdin is not considered + # to be supplying image data + mock_stdin.isatty = lambda: True + test_shell.do_image_create(self.gc, args) + + mocked_create.assert_called_once_with(name='IMG-01', + myprop='myval') + utils.print_dict.assert_called_once_with({ + 'id': 'pass', 'name': 'IMG-01', 'myprop': 'myval'}) + + def test_do_image_update_no_user_props(self): + args = self._make_args({'id': 'pass', 'name': 'IMG-01', + 'disk_format': 'vhd', + 'container_format': 'bare'}) + with mock.patch.object(self.gc.images, 'update') as mocked_update: + ignore_fields = ['self', 'access', 'file', 'schema'] + expect_image = dict([(field, field) for field in ignore_fields]) + expect_image['id'] = 'pass' + expect_image['name'] = 'IMG-01' + expect_image['disk_format'] = 'vhd' + expect_image['container_format'] = 'bare' + mocked_update.return_value = expect_image + + test_shell.do_image_update(self.gc, args) + + mocked_update.assert_called_once_with('pass', + None, + name='IMG-01', + disk_format='vhd', + container_format='bare') + utils.print_dict.assert_called_once_with({ + 'id': 'pass', 'name': 'IMG-01', 'disk_format': 'vhd', + 'container_format': 'bare'}) + + def test_do_image_update_with_user_props(self): + args = self._make_args({'id': 'pass', 'name': 'IMG-01', + 'property': ['myprop=myval']}) + with mock.patch.object(self.gc.images, 'update') as mocked_update: + ignore_fields = ['self', 'access', 'file', 'schema'] + expect_image = dict([(field, field) for field in ignore_fields]) + expect_image['id'] = 'pass' + expect_image['name'] = 'IMG-01' + expect_image['myprop'] = 'myval' + mocked_update.return_value = expect_image + + test_shell.do_image_update(self.gc, args) + + mocked_update.assert_called_once_with('pass', + None, + name='IMG-01', + myprop='myval') + utils.print_dict.assert_called_once_with({ + 'id': 'pass', 'name': 'IMG-01', 'myprop': 'myval'}) + + def test_do_image_update_with_remove_props(self): + args = self._make_args({'id': 'pass', 'name': 'IMG-01', + 'disk_format': 'vhd', + 'remove-property': ['container_format']}) + with mock.patch.object(self.gc.images, 'update') as mocked_update: + ignore_fields = ['self', 'access', 'file', 'schema'] + expect_image = dict([(field, field) for field in ignore_fields]) + expect_image['id'] = 'pass' + expect_image['name'] = 'IMG-01' + expect_image['disk_format'] = 'vhd' + + mocked_update.return_value = expect_image + + test_shell.do_image_update(self.gc, args) + + mocked_update.assert_called_once_with('pass', + ['container_format'], + name='IMG-01', + disk_format='vhd') + utils.print_dict.assert_called_once_with({ + 'id': 'pass', 'name': 'IMG-01', 'disk_format': 'vhd'}) + + def test_do_explain(self): + input = { + 'page_size': 18, + 'id': 'pass', + 'schemas': 'test', + 'model': 'test', + } + args = self._make_args(input) + with mock.patch.object(utils, 'print_list'): + test_shell.do_explain(self.gc, args) + + self.gc.schemas.get.assert_called_once_with('test') + + def test_do_location_add(self): + gc = self.gc + loc = {'url': 'http://foo.com/', 'metadata': {'foo': 'bar'}} + args = self._make_args({'id': 'pass', + 'url': loc['url'], + 'metadata': json.dumps(loc['metadata'])}) + with mock.patch.object(gc.images, 'add_location') as mocked_addloc: + expect_image = {'id': 'pass', 'locations': [loc]} + mocked_addloc.return_value = expect_image + + test_shell.do_location_add(self.gc, args) + mocked_addloc.assert_called_once_with('pass', + loc['url'], + loc['metadata']) + utils.print_dict.assert_called_once_with(expect_image) + + def test_do_location_delete(self): + gc = self.gc + loc_set = set(['http://foo/bar', 'http://spam/ham']) + args = self._make_args({'id': 'pass', 'url': loc_set}) + + with mock.patch.object(gc.images, 'delete_locations') as mocked_rmloc: + test_shell.do_location_delete(self.gc, args) + mocked_rmloc.assert_called_once_with('pass', loc_set) + + def test_do_location_update(self): + gc = self.gc + loc = {'url': 'http://foo.com/', 'metadata': {'foo': 'bar'}} + args = self._make_args({'id': 'pass', + 'url': loc['url'], + 'metadata': json.dumps(loc['metadata'])}) + with mock.patch.object(gc.images, 'update_location') as mocked_modloc: + expect_image = {'id': 'pass', 'locations': [loc]} + mocked_modloc.return_value = expect_image + + test_shell.do_location_update(self.gc, args) + mocked_modloc.assert_called_once_with('pass', + loc['url'], + loc['metadata']) + utils.print_dict.assert_called_once_with(expect_image) + + def test_image_upload(self): + args = self._make_args( + {'id': 'IMG-01', 'file': 'test', 'size': 1024, 'progress': False}) + + with mock.patch.object(self.gc.images, 'upload') as mocked_upload: + utils.get_data_file = mock.Mock(return_value='testfile') + mocked_upload.return_value = None + test_shell.do_image_upload(self.gc, args) + mocked_upload.assert_called_once_with('IMG-01', 'testfile', 1024) + + def test_image_download(self): + args = self._make_args( + {'id': 'IMG-01', 'file': 'test', 'progress': True}) + + with mock.patch.object(self.gc.images, 'data') as mocked_data: + def _data(): + for c in 'abcedf': + yield c + mocked_data.return_value = utils.IterableWithLength(_data(), 5) + + test_shell.do_image_download(self.gc, args) + mocked_data.assert_called_once_with('IMG-01') + + def test_do_image_delete(self): + args = self._make_args({'id': 'pass', 'file': 'test'}) + with mock.patch.object(self.gc.images, 'delete') as mocked_delete: + mocked_delete.return_value = 0 + + test_shell.do_image_delete(self.gc, args) + + mocked_delete.assert_called_once_with('pass') + + def test_do_image_delete_deleted(self): + image_id = 'deleted-img' + args = self._make_args({'id': image_id}) + with mock.patch.object(self.gc.images, 'get') as mocked_get: + mocked_get.return_value = self._make_args({'id': image_id, + 'status': 'deleted'}) + + msg = "No image with an ID of '%s' exists." % image_id + self.assert_exits_with_msg(func=test_shell.do_image_delete, + func_args=args, + err_msg=msg) + + def test_do_member_list(self): + args = self._make_args({'image_id': 'IMG-01'}) + with mock.patch.object(self.gc.image_members, 'list') as mocked_list: + mocked_list.return_value = {} + + test_shell.do_member_list(self.gc, args) + + mocked_list.assert_called_once_with('IMG-01') + columns = ['Image ID', 'Member ID', 'Status'] + utils.print_list.assert_called_once_with({}, columns) + + def test_do_member_create(self): + args = self._make_args({'image_id': 'IMG-01', 'member_id': 'MEM-01'}) + with mock.patch.object(self.gc.image_members, 'create') as mock_create: + mock_create.return_value = {} + + test_shell.do_member_create(self.gc, args) + + mock_create.assert_called_once_with('IMG-01', 'MEM-01') + columns = ['Image ID', 'Member ID', 'Status'] + utils.print_list.assert_called_once_with([{}], columns) + + def test_do_member_create_with_few_arguments(self): + args = self._make_args({'image_id': None, 'member_id': 'MEM-01'}) + msg = 'Unable to create member. Specify image_id and member_id' + + self.assert_exits_with_msg(func=test_shell.do_member_create, + func_args=args, + err_msg=msg) + + def test_do_member_update(self): + input = { + 'image_id': 'IMG-01', + 'member_id': 'MEM-01', + 'member_status': 'status', + } + args = self._make_args(input) + with mock.patch.object(self.gc.image_members, 'update') as mock_update: + mock_update.return_value = {} + + test_shell.do_member_update(self.gc, args) + + mock_update.assert_called_once_with('IMG-01', 'MEM-01', 'status') + columns = ['Image ID', 'Member ID', 'Status'] + utils.print_list.assert_called_once_with([{}], columns) + + def test_do_member_update_with_few_arguments(self): + input = { + 'image_id': 'IMG-01', + 'member_id': 'MEM-01', + 'member_status': None, + } + args = self._make_args(input) + msg = 'Unable to update member. Specify image_id, member_id' \ + ' and member_status' + + self.assert_exits_with_msg(func=test_shell.do_member_update, + func_args=args, + err_msg=msg) + + def test_do_member_delete(self): + args = self._make_args({'image_id': 'IMG-01', 'member_id': 'MEM-01'}) + with mock.patch.object(self.gc.image_members, 'delete') as mock_delete: + test_shell.do_member_delete(self.gc, args) + + mock_delete.assert_called_once_with('IMG-01', 'MEM-01') + + def test_do_member_delete_with_few_arguments(self): + args = self._make_args({'image_id': None, 'member_id': 'MEM-01'}) + msg = 'Unable to delete member. Specify image_id and member_id' + + self.assert_exits_with_msg(func=test_shell.do_member_delete, + func_args=args, + err_msg=msg) + + def test_image_tag_update(self): + args = self._make_args({'image_id': 'IMG-01', 'tag_value': 'tag01'}) + with mock.patch.object(self.gc.image_tags, 'update') as mocked_update: + self.gc.images.get = mock.Mock(return_value={}) + mocked_update.return_value = None + + test_shell.do_image_tag_update(self.gc, args) + + mocked_update.assert_called_once_with('IMG-01', 'tag01') + + def test_image_tag_update_with_few_arguments(self): + args = self._make_args({'image_id': None, 'tag_value': 'tag01'}) + msg = 'Unable to update tag. Specify image_id and tag_value' + + self.assert_exits_with_msg(func=test_shell.do_image_tag_update, + func_args=args, + err_msg=msg) + + def test_image_tag_delete(self): + args = self._make_args({'image_id': 'IMG-01', 'tag_value': 'tag01'}) + with mock.patch.object(self.gc.image_tags, 'delete') as mocked_delete: + mocked_delete.return_value = None + + test_shell.do_image_tag_delete(self.gc, args) + + mocked_delete.assert_called_once_with('IMG-01', 'tag01') + + def test_image_tag_delete_with_few_arguments(self): + args = self._make_args({'image_id': 'IMG-01', 'tag_value': None}) + msg = 'Unable to delete tag. Specify image_id and tag_value' + + self.assert_exits_with_msg(func=test_shell.do_image_tag_delete, + func_args=args, + err_msg=msg) + + def test_do_md_namespace_create(self): + args = self._make_args({'namespace': 'MyNamespace', + 'protected': True}) + with mock.patch.object(self.gc.metadefs_namespace, + 'create') as mocked_create: + expect_namespace = {} + expect_namespace['namespace'] = 'MyNamespace' + expect_namespace['protected'] = True + + mocked_create.return_value = expect_namespace + + test_shell.do_md_namespace_create(self.gc, args) + + mocked_create.assert_called_once_with(namespace='MyNamespace', + protected=True) + utils.print_dict.assert_called_once_with(expect_namespace) + + def test_do_md_namespace_import(self): + args = self._make_args({'file': 'test'}) + + expect_namespace = {} + expect_namespace['namespace'] = 'MyNamespace' + expect_namespace['protected'] = True + + with mock.patch.object(self.gc.metadefs_namespace, + 'create') as mocked_create: + mock_read = mock.Mock(return_value=json.dumps(expect_namespace)) + mock_file = mock.Mock(read=mock_read) + utils.get_data_file = mock.Mock(return_value=mock_file) + mocked_create.return_value = expect_namespace + + test_shell.do_md_namespace_import(self.gc, args) + + mocked_create.assert_called_once_with(**expect_namespace) + utils.print_dict.assert_called_once_with(expect_namespace) + + def test_do_md_namespace_import_invalid_json(self): + args = self._make_args({'file': 'test'}) + mock_read = mock.Mock(return_value='Invalid') + mock_file = mock.Mock(read=mock_read) + utils.get_data_file = mock.Mock(return_value=mock_file) + + self.assertRaises(SystemExit, test_shell.do_md_namespace_import, + self.gc, args) + + def test_do_md_namespace_import_no_input(self): + args = self._make_args({'file': None}) + utils.get_data_file = mock.Mock(return_value=None) + + self.assertRaises(SystemExit, test_shell.do_md_namespace_import, + self.gc, args) + + def test_do_md_namespace_update(self): + args = self._make_args({'id': 'MyNamespace', + 'protected': True}) + with mock.patch.object(self.gc.metadefs_namespace, + 'update') as mocked_update: + expect_namespace = {} + expect_namespace['namespace'] = 'MyNamespace' + expect_namespace['protected'] = True + + mocked_update.return_value = expect_namespace + + test_shell.do_md_namespace_update(self.gc, args) + + mocked_update.assert_called_once_with('MyNamespace', + id='MyNamespace', + protected=True) + utils.print_dict.assert_called_once_with(expect_namespace) + + def test_do_md_namespace_show(self): + args = self._make_args({'namespace': 'MyNamespace', + 'max_column_width': 80, + 'resource_type': None}) + with mock.patch.object(self.gc.metadefs_namespace, + 'get') as mocked_get: + expect_namespace = {} + expect_namespace['namespace'] = 'MyNamespace' + + mocked_get.return_value = expect_namespace + + test_shell.do_md_namespace_show(self.gc, args) + + mocked_get.assert_called_once_with('MyNamespace') + utils.print_dict.assert_called_once_with(expect_namespace, 80) + + def test_do_md_namespace_show_resource_type(self): + args = self._make_args({'namespace': 'MyNamespace', + 'max_column_width': 80, + 'resource_type': 'RESOURCE'}) + with mock.patch.object(self.gc.metadefs_namespace, + 'get') as mocked_get: + expect_namespace = {} + expect_namespace['namespace'] = 'MyNamespace' + + mocked_get.return_value = expect_namespace + + test_shell.do_md_namespace_show(self.gc, args) + + mocked_get.assert_called_once_with('MyNamespace', + resource_type='RESOURCE') + utils.print_dict.assert_called_once_with(expect_namespace, 80) + + def test_do_md_namespace_list(self): + args = self._make_args({'resource_type': None, + 'visibility': None, + 'page_size': None}) + with mock.patch.object(self.gc.metadefs_namespace, + 'list') as mocked_list: + expect_namespaces = [{'namespace': 'MyNamespace'}] + + mocked_list.return_value = expect_namespaces + + test_shell.do_md_namespace_list(self.gc, args) + + mocked_list.assert_called_once_with(filters={}) + utils.print_list.assert_called_once_with(expect_namespaces, + ['namespace']) + + def test_do_md_namespace_list_page_size(self): + args = self._make_args({'resource_type': None, + 'visibility': None, + 'page_size': 2}) + with mock.patch.object(self.gc.metadefs_namespace, + 'list') as mocked_list: + expect_namespaces = [{'namespace': 'MyNamespace'}] + + mocked_list.return_value = expect_namespaces + + test_shell.do_md_namespace_list(self.gc, args) + + mocked_list.assert_called_once_with(filters={}, page_size=2) + utils.print_list.assert_called_once_with(expect_namespaces, + ['namespace']) + + def test_do_md_namespace_list_one_filter(self): + args = self._make_args({'resource_types': ['OS::Compute::Aggregate'], + 'visibility': None, + 'page_size': None}) + with mock.patch.object(self.gc.metadefs_namespace, 'list') as \ + mocked_list: + expect_namespaces = [{'namespace': 'MyNamespace'}] + + mocked_list.return_value = expect_namespaces + + test_shell.do_md_namespace_list(self.gc, args) + + mocked_list.assert_called_once_with(filters={ + 'resource_types': ['OS::Compute::Aggregate']}) + utils.print_list.assert_called_once_with(expect_namespaces, + ['namespace']) + + def test_do_md_namespace_list_all_filters(self): + args = self._make_args({'resource_types': ['OS::Compute::Aggregate'], + 'visibility': 'public', + 'page_size': None}) + with mock.patch.object(self.gc.metadefs_namespace, + 'list') as mocked_list: + expect_namespaces = [{'namespace': 'MyNamespace'}] + + mocked_list.return_value = expect_namespaces + + test_shell.do_md_namespace_list(self.gc, args) + + mocked_list.assert_called_once_with(filters={ + 'resource_types': ['OS::Compute::Aggregate'], + 'visibility': 'public'}) + utils.print_list.assert_called_once_with(expect_namespaces, + ['namespace']) + + def test_do_md_namespace_list_unknown_filter(self): + args = self._make_args({'resource_type': None, + 'visibility': None, + 'some_arg': 'some_value', + 'page_size': None}) + with mock.patch.object(self.gc.metadefs_namespace, + 'list') as mocked_list: + expect_namespaces = [{'namespace': 'MyNamespace'}] + + mocked_list.return_value = expect_namespaces + + test_shell.do_md_namespace_list(self.gc, args) + + mocked_list.assert_called_once_with(filters={}) + utils.print_list.assert_called_once_with(expect_namespaces, + ['namespace']) + + def test_do_md_namespace_delete(self): + args = self._make_args({'namespace': 'MyNamespace', + 'content': False}) + with mock.patch.object(self.gc.metadefs_namespace, 'delete') as \ + mocked_delete: + test_shell.do_md_namespace_delete(self.gc, args) + + mocked_delete.assert_called_once_with('MyNamespace') + + def test_do_md_resource_type_associate(self): + args = self._make_args({'namespace': 'MyNamespace', + 'name': 'MyResourceType', + 'prefix': 'PREFIX:'}) + with mock.patch.object(self.gc.metadefs_resource_type, + 'associate') as mocked_associate: + expect_rt = {} + expect_rt['namespace'] = 'MyNamespace' + expect_rt['name'] = 'MyResourceType' + expect_rt['prefix'] = 'PREFIX:' + + mocked_associate.return_value = expect_rt + + test_shell.do_md_resource_type_associate(self.gc, args) + + mocked_associate.assert_called_once_with('MyNamespace', + **expect_rt) + utils.print_dict.assert_called_once_with(expect_rt) + + def test_do_md_resource_type_deassociate(self): + args = self._make_args({'namespace': 'MyNamespace', + 'resource_type': 'MyResourceType'}) + with mock.patch.object(self.gc.metadefs_resource_type, + 'deassociate') as mocked_deassociate: + test_shell.do_md_resource_type_deassociate(self.gc, args) + + mocked_deassociate.assert_called_once_with('MyNamespace', + 'MyResourceType') + + def test_do_md_resource_type_list(self): + args = self._make_args({}) + with mock.patch.object(self.gc.metadefs_resource_type, + 'list') as mocked_list: + expect_objects = ['MyResourceType1', 'MyResourceType2'] + + mocked_list.return_value = expect_objects + + test_shell.do_md_resource_type_list(self.gc, args) + + mocked_list.assert_called_once() + + def test_do_md_namespace_resource_type_list(self): + args = self._make_args({'namespace': 'MyNamespace'}) + with mock.patch.object(self.gc.metadefs_resource_type, + 'get') as mocked_get: + expect_objects = [{'namespace': 'MyNamespace', + 'object': 'MyObject'}] + + mocked_get.return_value = expect_objects + + test_shell.do_md_namespace_resource_type_list(self.gc, args) + + mocked_get.assert_called_once_with('MyNamespace') + utils.print_list.assert_called_once_with(expect_objects, + ['name', 'prefix', + 'properties_target']) + + def test_do_md_property_create(self): + args = self._make_args({'namespace': 'MyNamespace', + 'name': "MyProperty", + 'title': "Title", + 'schema': '{}'}) + with mock.patch.object(self.gc.metadefs_property, + 'create') as mocked_create: + expect_property = {} + expect_property['namespace'] = 'MyNamespace' + expect_property['name'] = 'MyProperty' + expect_property['title'] = 'Title' + + mocked_create.return_value = expect_property + + test_shell.do_md_property_create(self.gc, args) + + mocked_create.assert_called_once_with('MyNamespace', + name='MyProperty', + title='Title') + utils.print_dict.assert_called_once_with(expect_property) + + def test_do_md_property_create_invalid_schema(self): + args = self._make_args({'namespace': 'MyNamespace', + 'name': "MyProperty", + 'title': "Title", + 'schema': 'Invalid'}) + self.assertRaises(SystemExit, test_shell.do_md_property_create, + self.gc, args) + + def test_do_md_property_update(self): + args = self._make_args({'namespace': 'MyNamespace', + 'property': 'MyProperty', + 'name': 'NewName', + 'title': "Title", + 'schema': '{}'}) + with mock.patch.object(self.gc.metadefs_property, + 'update') as mocked_update: + expect_property = {} + expect_property['namespace'] = 'MyNamespace' + expect_property['name'] = 'MyProperty' + expect_property['title'] = 'Title' + + mocked_update.return_value = expect_property + + test_shell.do_md_property_update(self.gc, args) + + mocked_update.assert_called_once_with('MyNamespace', 'MyProperty', + name='NewName', + title='Title') + utils.print_dict.assert_called_once_with(expect_property) + + def test_do_md_property_update_invalid_schema(self): + args = self._make_args({'namespace': 'MyNamespace', + 'property': 'MyProperty', + 'name': "MyObject", + 'title': "Title", + 'schema': 'Invalid'}) + self.assertRaises(SystemExit, test_shell.do_md_property_update, + self.gc, args) + + def test_do_md_property_show(self): + args = self._make_args({'namespace': 'MyNamespace', + 'property': 'MyProperty', + 'max_column_width': 80}) + with mock.patch.object(self.gc.metadefs_property, 'get') as mocked_get: + expect_property = {} + expect_property['namespace'] = 'MyNamespace' + expect_property['property'] = 'MyProperty' + expect_property['title'] = 'Title' + + mocked_get.return_value = expect_property + + test_shell.do_md_property_show(self.gc, args) + + mocked_get.assert_called_once_with('MyNamespace', 'MyProperty') + utils.print_dict.assert_called_once_with(expect_property, 80) + + def test_do_md_property_delete(self): + args = self._make_args({'namespace': 'MyNamespace', + 'property': 'MyProperty'}) + with mock.patch.object(self.gc.metadefs_property, + 'delete') as mocked_delete: + test_shell.do_md_property_delete(self.gc, args) + + mocked_delete.assert_called_once_with('MyNamespace', 'MyProperty') + + def test_do_md_namespace_property_delete(self): + args = self._make_args({'namespace': 'MyNamespace'}) + with mock.patch.object(self.gc.metadefs_property, + 'delete_all') as mocked_delete_all: + test_shell.do_md_namespace_properties_delete(self.gc, args) + + mocked_delete_all.assert_called_once_with('MyNamespace') + + def test_do_md_property_list(self): + args = self._make_args({'namespace': 'MyNamespace'}) + with mock.patch.object(self.gc.metadefs_property, + 'list') as mocked_list: + expect_objects = [{'namespace': 'MyNamespace', + 'property': 'MyProperty', + 'title': 'MyTitle'}] + + mocked_list.return_value = expect_objects + + test_shell.do_md_property_list(self.gc, args) + + mocked_list.assert_called_once_with('MyNamespace') + utils.print_list.assert_called_once_with(expect_objects, + ['name', 'title', 'type']) + + def test_do_md_object_create(self): + args = self._make_args({'namespace': 'MyNamespace', + 'name': "MyObject", + 'schema': '{}'}) + with mock.patch.object(self.gc.metadefs_object, + 'create') as mocked_create: + expect_object = {} + expect_object['namespace'] = 'MyNamespace' + expect_object['name'] = 'MyObject' + + mocked_create.return_value = expect_object + + test_shell.do_md_object_create(self.gc, args) + + mocked_create.assert_called_once_with('MyNamespace', + name='MyObject') + utils.print_dict.assert_called_once_with(expect_object) + + def test_do_md_object_create_invalid_schema(self): + args = self._make_args({'namespace': 'MyNamespace', + 'name': "MyObject", + 'schema': 'Invalid'}) + self.assertRaises(SystemExit, test_shell.do_md_object_create, + self.gc, args) + + def test_do_md_object_update(self): + args = self._make_args({'namespace': 'MyNamespace', + 'object': 'MyObject', + 'name': 'NewName', + 'schema': '{}'}) + with mock.patch.object(self.gc.metadefs_object, + 'update') as mocked_update: + expect_object = {} + expect_object['namespace'] = 'MyNamespace' + expect_object['name'] = 'MyObject' + + mocked_update.return_value = expect_object + + test_shell.do_md_object_update(self.gc, args) + + mocked_update.assert_called_once_with('MyNamespace', 'MyObject', + name='NewName') + utils.print_dict.assert_called_once_with(expect_object) + + def test_do_md_object_update_invalid_schema(self): + args = self._make_args({'namespace': 'MyNamespace', + 'object': 'MyObject', + 'name': "MyObject", + 'schema': 'Invalid'}) + self.assertRaises(SystemExit, test_shell.do_md_object_update, + self.gc, args) + + def test_do_md_object_show(self): + args = self._make_args({'namespace': 'MyNamespace', + 'object': 'MyObject', + 'max_column_width': 80}) + with mock.patch.object(self.gc.metadefs_object, 'get') as mocked_get: + expect_object = {} + expect_object['namespace'] = 'MyNamespace' + expect_object['object'] = 'MyObject' + + mocked_get.return_value = expect_object + + test_shell.do_md_object_show(self.gc, args) + + mocked_get.assert_called_once_with('MyNamespace', 'MyObject') + utils.print_dict.assert_called_once_with(expect_object, 80) + + def test_do_md_object_property_show(self): + args = self._make_args({'namespace': 'MyNamespace', + 'object': 'MyObject', + 'property': 'MyProperty', + 'max_column_width': 80}) + with mock.patch.object(self.gc.metadefs_object, 'get') as mocked_get: + expect_object = {'name': 'MyObject', + 'properties': { + 'MyProperty': {'type': 'string'} + }} + + mocked_get.return_value = expect_object + + test_shell.do_md_object_property_show(self.gc, args) + + mocked_get.assert_called_once_with('MyNamespace', 'MyObject') + utils.print_dict.assert_called_once_with({'type': 'string', + 'name': 'MyProperty'}, + 80) + + def test_do_md_object_property_show_non_existing(self): + args = self._make_args({'namespace': 'MyNamespace', + 'object': 'MyObject', + 'property': 'MyProperty', + 'max_column_width': 80}) + with mock.patch.object(self.gc.metadefs_object, 'get') as mocked_get: + expect_object = {'name': 'MyObject', 'properties': {}} + mocked_get.return_value = expect_object + + self.assertRaises(SystemExit, + test_shell.do_md_object_property_show, + self.gc, args) + mocked_get.assert_called_once_with('MyNamespace', 'MyObject') + + def test_do_md_object_delete(self): + args = self._make_args({'namespace': 'MyNamespace', + 'object': 'MyObject'}) + with mock.patch.object(self.gc.metadefs_object, + 'delete') as mocked_delete: + test_shell.do_md_object_delete(self.gc, args) + + mocked_delete.assert_called_once_with('MyNamespace', 'MyObject') + + def test_do_md_namespace_objects_delete(self): + args = self._make_args({'namespace': 'MyNamespace'}) + with mock.patch.object(self.gc.metadefs_object, + 'delete_all') as mocked_delete_all: + test_shell.do_md_namespace_objects_delete(self.gc, args) + + mocked_delete_all.assert_called_once_with('MyNamespace') + + def test_do_md_object_list(self): + args = self._make_args({'namespace': 'MyNamespace'}) + with mock.patch.object(self.gc.metadefs_object, 'list') as mocked_list: + expect_objects = [{'namespace': 'MyNamespace', + 'object': 'MyObject'}] + + mocked_list.return_value = expect_objects + + test_shell.do_md_object_list(self.gc, args) + + mocked_list.assert_called_once_with('MyNamespace') + utils.print_list.assert_called_once_with( + expect_objects, + ['name', 'description'], + field_settings={ + 'description': {'align': 'l', 'max_width': 50}}) diff --git a/glanceclient/tests/unit/v2/test_tags.py b/glanceclient/tests/unit/v2/test_tags.py new file mode 100644 index 0000000..790080e --- /dev/null +++ b/glanceclient/tests/unit/v2/test_tags.py @@ -0,0 +1,81 @@ +# Copyright 2013 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 testtools + +from glanceclient.tests import utils +from glanceclient.v2 import image_tags + + +IMAGE = '3a4560a1-e585-443e-9b39-553b46ec92d1' +TAG = 'tag01' + + +data_fixtures = { + '/v2/images/{image}/tags/{tag_value}'.format(image=IMAGE, tag_value=TAG): { + 'DELETE': ( + {}, + None, + ), + 'PUT': ( + {}, + { + 'image_id': IMAGE, + 'tag_value': TAG + } + ), + } +} + +schema_fixtures = { + 'tag': { + 'GET': ( + {}, + {'name': 'image', 'properties': {'image_id': {}, 'tags': {}}} + ) + } +} + + +class TestController(testtools.TestCase): + def setUp(self): + super(TestController, self).setUp() + self.api = utils.FakeAPI(data_fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = image_tags.Controller(self.api, self.schema_api) + + def test_update_image_tag(self): + image_id = IMAGE + tag_value = TAG + self.controller.update(image_id, tag_value) + expect = [ + ('PUT', + '/v2/images/{image}/tags/{tag_value}'.format(image=IMAGE, + tag_value=TAG), + {}, + None)] + self.assertEqual(expect, self.api.calls) + + def test_delete_image_tag(self): + image_id = IMAGE + tag_value = TAG + self.controller.delete(image_id, tag_value) + expect = [ + ('DELETE', + '/v2/images/{image}/tags/{tag_value}'.format(image=IMAGE, + tag_value=TAG), + {}, + None)] + self.assertEqual(expect, self.api.calls) diff --git a/glanceclient/tests/unit/v2/test_tasks.py b/glanceclient/tests/unit/v2/test_tasks.py new file mode 100644 index 0000000..ed63286 --- /dev/null +++ b/glanceclient/tests/unit/v2/test_tasks.py @@ -0,0 +1,285 @@ +# Copyright 2013 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 testtools + +from glanceclient.tests import utils +from glanceclient.v2 import tasks + + +_OWNED_TASK_ID = 'a4963502-acc7-42ba-ad60-5aa0962b7faf' +_OWNER_ID = '6bd473f0-79ae-40ad-a927-e07ec37b642f' +_FAKE_OWNER_ID = '63e7f218-29de-4477-abdc-8db7c9533188' + + +fixtures = { + '/v2/tasks?limit=%d' % tasks.DEFAULT_PAGE_SIZE: { + 'GET': ( + {}, + {'tasks': [ + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'type': 'import', + 'status': 'pending', + }, + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'type': 'import', + 'status': 'processing', + }, + ]}, + ), + }, + '/v2/tasks?limit=1': { + 'GET': ( + {}, + { + 'tasks': [ + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'type': 'import', + 'status': 'pending', + }, + ], + 'next': ('/v2/tasks?limit=1&' + 'marker=3a4560a1-e585-443e-9b39-553b46ec92d1'), + }, + ), + }, + ('/v2/tasks?limit=1&marker=3a4560a1-e585-443e-9b39-553b46ec92d1'): { + 'GET': ( + {}, + {'tasks': [ + { + 'id': '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810', + 'type': 'import', + 'status': 'pending', + }, + ]}, + ), + }, + '/v2/tasks/3a4560a1-e585-443e-9b39-553b46ec92d1': { + 'GET': ( + {}, + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'type': 'import', + 'status': 'pending', + }, + ), + 'PATCH': ( + {}, + '', + ), + }, + '/v2/tasks/e7e59ff6-fa2e-4075-87d3-1a1398a07dc3': { + 'GET': ( + {}, + { + 'id': 'e7e59ff6-fa2e-4075-87d3-1a1398a07dc3', + 'type': 'import', + 'status': 'pending', + }, + ), + 'PATCH': ( + {}, + '', + ), + }, + '/v2/tasks': { + 'POST': ( + {}, + { + 'id': '3a4560a1-e585-443e-9b39-553b46ec92d1', + 'type': 'import', + 'status': 'pending', + 'input': '{"import_from": "file:///", ' + '"import_from_format": "qcow2"}' + }, + ), + }, + '/v2/tasks?limit=%d&owner=%s' % (tasks.DEFAULT_PAGE_SIZE, _OWNER_ID): { + 'GET': ( + {}, + {'tasks': [ + { + 'id': _OWNED_TASK_ID, + }, + ]}, + ), + }, + '/v2/tasks?limit=%d&status=processing' % (tasks.DEFAULT_PAGE_SIZE): { + 'GET': ( + {}, + {'tasks': [ + { + 'id': _OWNED_TASK_ID, + }, + ]}, + ), + }, + '/v2/tasks?limit=%d&type=import' % (tasks.DEFAULT_PAGE_SIZE): { + 'GET': ( + {}, + {'tasks': [ + { + 'id': _OWNED_TASK_ID, + }, + ]}, + ), + }, + '/v2/tasks?limit=%d&type=fake' % (tasks.DEFAULT_PAGE_SIZE): { + 'GET': ( + {}, + {'tasks': [ + ]}, + ), + }, + '/v2/tasks?limit=%d&status=fake' % (tasks.DEFAULT_PAGE_SIZE): { + 'GET': ( + {}, + {'tasks': [ + ]}, + ), + }, + '/v2/tasks?limit=%d&type=import' % (tasks.DEFAULT_PAGE_SIZE): { + 'GET': ( + {}, + {'tasks': [ + { + 'id': _OWNED_TASK_ID, + }, + ]}, + ), + }, + '/v2/tasks?limit=%d&owner=%s' % (tasks.DEFAULT_PAGE_SIZE, _FAKE_OWNER_ID): + { + 'GET': ({}, + {'tasks': []}, + ), + } +} + +schema_fixtures = { + 'task': { + 'GET': ( + {}, + { + 'name': 'task', + 'properties': { + 'id': {}, + 'type': {}, + 'status': {}, + 'input': {}, + 'result': {}, + 'message': {}, + }, + 'additionalProperties': False, + } + ) + } +} + + +class TestController(testtools.TestCase): + def setUp(self): + super(TestController, self).setUp() + self.api = utils.FakeAPI(fixtures) + self.schema_api = utils.FakeSchemaAPI(schema_fixtures) + self.controller = tasks.Controller(self.api, self.schema_api) + + def test_list_tasks(self): + #NOTE(flwang): cast to list since the controller returns a generator + tasks = list(self.controller.list()) + self.assertEqual(tasks[0].id, '3a4560a1-e585-443e-9b39-553b46ec92d1') + self.assertEqual(tasks[0].type, 'import') + self.assertEqual(tasks[0].status, 'pending') + self.assertEqual(tasks[1].id, '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810') + self.assertEqual(tasks[1].type, 'import') + self.assertEqual(tasks[1].status, 'processing') + + def test_list_tasks_paginated(self): + #NOTE(flwang): cast to list since the controller returns a generator + tasks = list(self.controller.list(page_size=1)) + self.assertEqual(tasks[0].id, '3a4560a1-e585-443e-9b39-553b46ec92d1') + self.assertEqual(tasks[0].type, 'import') + self.assertEqual(tasks[1].id, '6f99bf80-2ee6-47cf-acfe-1f1fabb7e810') + self.assertEqual(tasks[1].type, 'import') + + def test_list_tasks_with_status(self): + filters = {'filters': {'status': 'processing'}} + tasks = list(self.controller.list(**filters)) + self.assertEqual(tasks[0].id, _OWNED_TASK_ID) + + def test_list_tasks_with_wrong_status(self): + filters = {'filters': {'status': 'fake'}} + tasks = list(self.controller.list(**filters)) + self.assertEqual(len(tasks), 0) + + def test_list_tasks_with_type(self): + filters = {'filters': {'type': 'import'}} + tasks = list(self.controller.list(**filters)) + self.assertEqual(tasks[0].id, _OWNED_TASK_ID) + + def test_list_tasks_with_wrong_type(self): + filters = {'filters': {'type': 'fake'}} + tasks = list(self.controller.list(**filters)) + self.assertEqual(len(tasks), 0) + + def test_list_tasks_for_owner(self): + filters = {'filters': {'owner': _OWNER_ID}} + tasks = list(self.controller.list(**filters)) + self.assertEqual(tasks[0].id, _OWNED_TASK_ID) + + def test_list_tasks_for_fake_owner(self): + filters = {'filters': {'owner': _FAKE_OWNER_ID}} + tasks = list(self.controller.list(**filters)) + self.assertEqual(tasks, []) + + def test_list_tasks_filters_encoding(self): + filters = {"owner": u"ni\xf1o"} + try: + list(self.controller.list(filters=filters)) + except KeyError: + # NOTE(flaper87): It raises KeyError because there's + # no fixture supporting this query: + # /v2/tasks?owner=ni%C3%B1o&limit=20 + # We just want to make sure filters are correctly encoded. + pass + + self.assertEqual(b"ni\xc3\xb1o", filters["owner"]) + + def test_get_task(self): + task = self.controller.get('3a4560a1-e585-443e-9b39-553b46ec92d1') + self.assertEqual(task.id, '3a4560a1-e585-443e-9b39-553b46ec92d1') + self.assertEqual(task.type, 'import') + + def test_create_task(self): + properties = { + 'type': 'import', + 'input': {'import_from_format': 'ovf', 'import_from': + 'swift://cloud.foo/myaccount/mycontainer/path'}, + } + task = self.controller.create(**properties) + self.assertEqual(task.id, '3a4560a1-e585-443e-9b39-553b46ec92d1') + self.assertEqual(task.type, 'import') + + def test_create_task_invalid_property(self): + properties = { + 'type': 'import', + 'bad_prop': 'value', + } + self.assertRaises(TypeError, self.controller.create, **properties) diff --git a/glanceclient/tests/unit/var/ca.crt b/glanceclient/tests/unit/var/ca.crt new file mode 100644 index 0000000..c149d8c --- /dev/null +++ b/glanceclient/tests/unit/var/ca.crt @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +MIIF7jCCA9YCCQDbl9qx7iIeJDANBgkqhkiG9w0BAQUFADCBuDEZMBcGA1UEChMQ +T3BlbnN0YWNrIENBIE9yZzEaMBgGA1UECxMRT3BlbnN0YWNrIFRlc3QgQ0ExIzAh +BgkqhkiG9w0BCQEWFGFkbWluQGNhLmV4YW1wbGUuY29tMREwDwYDVQQHEwhTdGF0 +ZSBDQTELMAkGA1UECBMCQ0ExCzAJBgNVBAYTAkFVMS0wKwYDVQQDEyRPcGVuc3Rh +Y2sgVGVzdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMTIxMTE2MTI1MDE2WhcN +NDAwNDAzMTI1MDE2WjCBuDEZMBcGA1UEChMQT3BlbnN0YWNrIENBIE9yZzEaMBgG +A1UECxMRT3BlbnN0YWNrIFRlc3QgQ0ExIzAhBgkqhkiG9w0BCQEWFGFkbWluQGNh +LmV4YW1wbGUuY29tMREwDwYDVQQHEwhTdGF0ZSBDQTELMAkGA1UECBMCQ0ExCzAJ +BgNVBAYTAkFVMS0wKwYDVQQDEyRPcGVuc3RhY2sgVGVzdCBDZXJ0aWZpY2F0ZSBB +dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC94cpBjwj2 +MD0w5j1Jlcy8Ljmk3r7CRaoV5vhWUrAWpT7Thxr/Ti0qAfZZRSIVpvBM0RlseH0Q +toUJixuYMoNRPUQ74r/TRoO8HfjQDJfnXtWg2L7DRP8p4Zgj3vByBUCU+rKsbI/H +Nssl/AronADbZXCoL5hJRN8euMYZGrt/Gh1ZotKE5gQlEjylDFlA3s3pn+ABLgzf +7L7iufwV3zLdPRHCb6Ve8YvUmKfI6gy+WwTRhNhLz4Nj0uBthnj6QhnRXtxkNT7A +aAStqKH6TtYRnk2Owh8ITFbtLQ0/MSV8jHAxMXx9AloBhEKxv3cIpgLH6lOCnj// +Ql+H6/QWtmTUHzP1kBfMhTQnWTfR92QTcgEMiZ7a07VyVtLh+kp/G5IUqpM6Pyz/ +O6QDs7FF69bTpws7Ce916PPrGFZ9Gqvo/P0jXge8kYqO+a8QnTRldAxdUzPJCK9+ +Dyi2LWeHf8nPFYdwW9Ov6Jw1CKDYxjJg6KIwnrMPa2eUdPB6/OKkqr9/KemOoKQu +4KSaYadFZbaJwt7JPZaHy6TpkGxW7Af8RqGrW6a6nWEFcfO2POuHcAHWL5LiRmni +unm60DBF3b3itDTqCvER3mZE9pN8dqtxdpB8SUX8eq0UJJK2K8mJQS+oE9crbqYb +1kQbYjhhPLlvOQru+/m/abqZrC04u2OtYQIDAQABMA0GCSqGSIb3DQEBBQUAA4IC +AQA8wGVBbzfpQ3eYpchiHyHF9N5LIhr6Bt4jYDKLz8DIbElLtoOlgH/v7hLGJ7wu +R9OteonwQ1qr9umMmnp61bKXOEBJLBJbGKEt0MNLmmX89+M/h3rdMVZEz/Hht/xK +Xm4di8pjkHfmdhqsbiFW81lAt9W1r74lnH7wQHr9ueALGKDx0hi8pAZ27itgQVHL +eA1erhw0kjr9BqWpDIskVwePcD7pFoZ48GQlST0uIEq5U+1AWq7AbOABsqODygKi +Ri5pmTasNFT7nEX3ti4VN214MNy0JnPzTRNWR2rD0I30AebM3KkzTprbLVfnGkm4 +7hOPV+Wc8EjgbbrUAIp2YpOfO/9nbgljTOUsqfjqxzvHx/09XOo2M6NIE5UiHqIq +TXN7CeGIhBoYbvBAH2QvtveFXv41IYL4zFFXo4wTBSzCCOUGeDDv0U4hhsNaCkDQ +G2TcubNA4g/FAtqLvPj/6VbIIgFE/1/6acsT+W0O+kkVAb7ej2dpI7J+jKXDXuiA +PDCMn9dVQ7oAcaQvVdvvRphLdIZ9wHgqKhxKsMwzIMExuDKL0lWe/3sueFyol6nv +xRCSgzr5MqSObbO3EnWgcUocBvlPyYLnTM2T8C5wh3BGnJXqJSRETggNn8PXBVIm ++c5o+Ic0mYu4v8P1ZSozFdgf+HLriVPwzJU5dHvvTEu7sw== +-----END CERTIFICATE----- diff --git a/glanceclient/tests/unit/var/certificate.crt b/glanceclient/tests/unit/var/certificate.crt new file mode 100644 index 0000000..06c02ab --- /dev/null +++ b/glanceclient/tests/unit/var/certificate.crt @@ -0,0 +1,66 @@ +# Certificate: +# Data: +# Version: 3 (0x2) +# Serial Number: 1 (0x1) +# Signature Algorithm: sha1WithRSAEncryption +# Issuer: O=Openstack CA Org, OU=Openstack Test CA/emailAddress=admin@ca.example.com, +# L=State CA, ST=CA, C=AU, CN=Openstack Test Certificate Authority +# Validity +# Not Before: Nov 16 12:50:19 2012 GMT +# Not After : Apr 3 12:50:19 2040 GMT +# Subject: O=Openstack Test Org, OU=Openstack Test Unit/emailAddress=admin@example.com, +# L=State1, ST=CA, C=US, CN=0.0.0.0 +# Subject Public Key Info: +# Public Key Algorithm: rsaEncryption +# RSA Public Key: (4096 bit) +# Modulus (4096 bit): +# 00:d4:bb:3a:c4:a0:06:54:31:23:5d:b0:78:5a:be: +# 45:44:ae:a1:89:86:11:d8:ca:a8:33:b0:4f:f3:e1: +# . +# . +# . +# Exponent: 65537 (0x10001) +# X509v3 extensions: +# X509v3 Subject Alternative Name: +# DNS:alt1.example.com, DNS:alt2.example.com +# Signature Algorithm: sha1WithRSAEncryption +# 2c:fc:5c:87:24:bd:4a:fa:40:d2:2e:35:a4:2a:f3:1c:b3:67: +# b0:e4:8a:cd:67:6b:55:50:d4:cb:dd:2d:26:a5:15:62:90:a3: +# . +# . +# . +-----BEGIN CERTIFICATE----- +MIIGADCCA+igAwIBAgIBATANBgkqhkiG9w0BAQUFADCBuDEZMBcGA1UEChMQT3Bl +bnN0YWNrIENBIE9yZzEaMBgGA1UECxMRT3BlbnN0YWNrIFRlc3QgQ0ExIzAhBgkq +hkiG9w0BCQEWFGFkbWluQGNhLmV4YW1wbGUuY29tMREwDwYDVQQHEwhTdGF0ZSBD +QTELMAkGA1UECBMCQ0ExCzAJBgNVBAYTAkFVMS0wKwYDVQQDEyRPcGVuc3RhY2sg +VGVzdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMTIxMTE2MTI1MDE5WhcNNDAw +NDAzMTI1MDE5WjCBmjEbMBkGA1UEChMST3BlbnN0YWNrIFRlc3QgT3JnMRwwGgYD +VQQLExNPcGVuc3RhY2sgVGVzdCBVbml0MSAwHgYJKoZIhvcNAQkBFhFhZG1pbkBl +eGFtcGxlLmNvbTEPMA0GA1UEBxMGU3RhdGUxMQswCQYDVQQIEwJDQTELMAkGA1UE +BhMCVVMxEDAOBgNVBAMTBzAuMC4wLjAwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw +ggIKAoICAQDUuzrEoAZUMSNdsHhavkVErqGJhhHYyqgzsE/z4UYehaMqnKTgwhQ0 +T5Hf3GmlIBt4I96/3cxj0qSLrdR81fM+5Km8lIlVHwVn1y6LKcMlaUC4K+sgDLcj +hZfbf9+fMkcur3WlNzKpAEaIosWwsu6YvYc+W/nPBpKxMbOZ4fZiPMEo8Pxmw7sl +/6hnlBOJj7dpZOZpHhVPZgzYNVoyfKCZiwgdxH4JEYa+EQos87+2Nwhs7bCgrTLL +ppCUvpobwZV5w4O0D6INpUfBmsr4IAuXeFWZa61vZYqhaVbAbTTlUzOLGh7Z2uz9 +gt75iSR2J0e2xntVaUIYLIAUNOO2edk8NMAuIOGr2EIyC7i2O/BTti2YjGNO7SsE +ClxiIFKjYahylHmNrS1Q/oMAcJppmhz+oOCmKOMmAZXYAH1A3gs/sWphJpgv/MWt +6Ji24VpFaJ+o4bHILlqIpuvL4GLIOkmxVP639khaumgKtgNIUTKJ/V6t/J31WARf +xKxlBQTTzV/Be+84YJiiddx8eunU8AorPyAJFzsDPTJpFUB4Q5BwAeDGCySgxJpU +qM2MTETBycdiVToM4SWkRsOZgZxQ+AVfkkqDct2Bat2lg9epcIez8PrsohQjQbmi +qUUL2c3de4kLYzIWF8EN3P2Me/7b06jbn4c7Fly/AN6tJOG23BzhHQIDAQABozEw +LzAtBgNVHREEJjAkghBhbHQxLmV4YW1wbGUuY29tghBhbHQyLmV4YW1wbGUuY29t +MA0GCSqGSIb3DQEBBQUAA4ICAQAs/FyHJL1K+kDSLjWkKvMcs2ew5IrNZ2tVUNTL +3S0mpRVikKOQbNLh5B6Q7eQIvilCdkuit7o2HrpxQHsRor5b4+LyjSLoltyE7dgr +ioP5nkKH+ujw6PtMxJCiKvvI+6cVHh6EV2ZkddvbJLVBVVZmB4H64xocS3rrQj19 +SXFYVrEjqdLzdGPNIBR+XVnTCeofXg1rkMaU7JuY8nRztee8PRVcKYX6scPfZJb8 ++Ea2dsTmtQP4H9mk+JiKGYhEeMLVmjiv3q7KIFownTKZ88K6QbpW2Nj66ItvphoT +QqI3rs6E8N0BhftiCcxXtXg+o4utfcnp8jTXX5tVnv44FqtWx7Gzg8XTLPri+ZEB +5IbgU4Q3qFicenBfjwZhH3+GNe52/wLVZLYjal5RPVSRdu9UEDeDAwTCMZSLF4lC +rc9giQCMnJ4ISi6C7xH+lDZGFqcJd4oXg/ue9aOJJAFTwhd83fdCHhUu431iPrts +NubfrHLMeUjluFgIWmhEZg+XTjB1SQeQzNaZiMODaAv4/40ZVKxvNpDFwIIsPUDf ++uC+fv1Q8+alqVMl2ouVyr8ut43HWNV6CJHXODvFp5irjxzVSgLtYDVUInkDFJEs +tFpTY21/zVAHIvsj2n4F1231nILR6vBp/WbwBY7r7j0oRtbaO3B1Q6tsbCZQRkKU +tdc5rw== +-----END CERTIFICATE----- diff --git a/glanceclient/tests/unit/var/expired-cert.crt b/glanceclient/tests/unit/var/expired-cert.crt new file mode 100644 index 0000000..227d422 --- /dev/null +++ b/glanceclient/tests/unit/var/expired-cert.crt @@ -0,0 +1,35 @@ +-----BEGIN CERTIFICATE----- +MIIGFTCCA/2gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBuDEZMBcGA1UEChMQT3Bl +bnN0YWNrIENBIE9yZzEaMBgGA1UECxMRT3BlbnN0YWNrIFRlc3QgQ0ExIzAhBgkq +hkiG9w0BCQEWFGFkbWluQGNhLmV4YW1wbGUuY29tMREwDwYDVQQHEwhTdGF0ZSBD +QTELMAkGA1UECBMCQ0ExCzAJBgNVBAYTAkFVMS0wKwYDVQQDEyRPcGVuc3RhY2sg +VGVzdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMTIxMTE1MTcwNjMzWhcNMTIx +MTE2MTcwNjMzWjCBqDEbMBkGA1UEChMST3BlbnN0YWNrIFRlc3QgT3JnMRwwGgYD +VQQLExNPcGVuc3RhY2sgVGVzdCBVbml0MSAwHgYJKoZIhvcNAQkBFhFhZG1pbkBl +eGFtcGxlLmNvbTEPMA0GA1UEBxMGU3RhdGUxMQswCQYDVQQIEwJDQTELMAkGA1UE +BhMCVVMxHjAcBgNVBAMTFW9wZW5zdGFjay5leGFtcGxlLmNvbTCCAiIwDQYJKoZI +hvcNAQEBBQADggIPADCCAgoCggIBANn9w82sGN+iALSlZ5/Odd5iJ3MAJ5BoalMG +kfUECGMewd7lE5+6ok1+vqVbYjd+F56aSkIJFR/ck51EYG2diGM5E5zjdiLcyB9l +dKB5PmaB2P9dHyomy+sMONqhw5uEsWKIfPbtjzGRhjJL0bIYwptGr4JPraZy8R3d +HWbTO3SlnFkjHHtfoKuZtRJq5OD1hXM8J9IEsBC90zw7RWCTw1iKllLfKITPUi7O +i8ITjUyTVKR2e56XRtmxGgGsGyZpcYrmhRuLo9jyL9m3VuNzsfwDvCqn7cnZIOQa +VO4hNZdO+33PINCC+YVNOGYwqfBuKxYvHJSbMfOZ6JDK98v65pWLBN7PObYIjQFH +uJyK5DuQMqvyRIcrtfLUalepD+PQaCn4ajgXjpqBz4t0pMte8jh0i4clLwvT0elT +PtA+MMos3hIGjJgEHTvLdCff9qlkjHlW7lg45PYn7S0Z7dqtBWD7Ys2B+AWp/skt +hRr7YZeegLfHVJVkMFL6Ojs98161W2FLmEA+5nejzjx7kWlJsg9aZPbBnN87m6iK +RHI+VkqSpBHm10iMlp4Nn30RtOj0wQhxoZjtEouGeRobHN5ULwpAfNEpKMMZf5bt +604JjOP9Pn+WzsvzGDeXjgxUP55PIR+EpHkvS5h1YQ+9RV5J669e2J9T4gnc0Abg +t3jJvtp1AgMBAAGjODA2MDQGA1UdEQQtMCuCEGFsdDEuZXhhbXBsZS5jb22BDm9z +QGV4YW1wbGUuY29tggcwLjAuMC4wMA0GCSqGSIb3DQEBBQUAA4ICAQBkKUA4lhsS +zjcuh77wtAIP9SN5Se4CheTRDXKDeuwWB6VQDzdJdtqSnWNF6sVEA97vhNTSjaBD +hfrtX9FZ+ImADlOf01t4Dakhsmje/DEPiQHaCy9P5fGtGIGRlWUyTmyQoV1LDLM5 +wgB1V5Oz2iDat2AdvUb0OFP0O1M887OgPpfUDQJEUTVAs5JS+6P/6RPyFh/dHWiX +UGoM0nMvTwsLWT4CZ9NdIChecVwBFqXjNytPY53tKbCWp77d/oGUg5Pb6EBD3xSW +AeMJ6PuafDRgm/He8nOtZnUd+53Ha59yzSGnSopu5WqrUa/xD+ZiK6dX7LsH/M8y +Hz0rh7w22qNHUxNaC3hrhx1BxX4au6z4kpKXIlAWH7ViRzVZ8XkwqqrndqWPWOFk +1emLLJ1dfT8FXdgpHenkUiktAf5qZhUWbF6nr9at+c4T7ZrLHSekux2r29kD9BJw +O2gSSclxKlMPwirUC0P4J/2WP72kCbf6AEfKU2siT12E6/xOmgen9lVYKckBiLbb +rJ97L1ieJI8GZTGExjtE9Lo+XVsv28D2XLU8vNCODs0xPZCr2TLNS/6YcnVy6594 +vpvU7fbNFAyxG4sjQC0wHoN6rn+kd1kzfprmBHKTx3W7y+hzjb+W7iS2EZn20k+N +l3+dFHnWayuCdqcFwIl3m8i8FupFihz9+A== +-----END CERTIFICATE----- diff --git a/glanceclient/tests/unit/var/privatekey.key b/glanceclient/tests/unit/var/privatekey.key new file mode 100644 index 0000000..5b47d44 --- /dev/null +++ b/glanceclient/tests/unit/var/privatekey.key @@ -0,0 +1,51 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIJKQIBAAKCAgEA1Ls6xKAGVDEjXbB4Wr5FRK6hiYYR2MqoM7BP8+FGHoWjKpyk +4MIUNE+R39xppSAbeCPev93MY9Kki63UfNXzPuSpvJSJVR8FZ9cuiynDJWlAuCvr +IAy3I4WX23/fnzJHLq91pTcyqQBGiKLFsLLumL2HPlv5zwaSsTGzmeH2YjzBKPD8 +ZsO7Jf+oZ5QTiY+3aWTmaR4VT2YM2DVaMnygmYsIHcR+CRGGvhEKLPO/tjcIbO2w +oK0yy6aQlL6aG8GVecODtA+iDaVHwZrK+CALl3hVmWutb2WKoWlWwG005VMzixoe +2drs/YLe+YkkdidHtsZ7VWlCGCyAFDTjtnnZPDTALiDhq9hCMgu4tjvwU7YtmIxj +Tu0rBApcYiBSo2GocpR5ja0tUP6DAHCaaZoc/qDgpijjJgGV2AB9QN4LP7FqYSaY +L/zFreiYtuFaRWifqOGxyC5aiKbry+BiyDpJsVT+t/ZIWrpoCrYDSFEyif1erfyd +9VgEX8SsZQUE081fwXvvOGCYonXcfHrp1PAKKz8gCRc7Az0yaRVAeEOQcAHgxgsk +oMSaVKjNjExEwcnHYlU6DOElpEbDmYGcUPgFX5JKg3LdgWrdpYPXqXCHs/D67KIU +I0G5oqlFC9nN3XuJC2MyFhfBDdz9jHv+29Oo25+HOxZcvwDerSThttwc4R0CAwEA +AQKCAgEAqnwqSu4cZFjFCQ6mRcL67GIvn3FM2DsBtfr0+HRvp4JeE4ZaNK4VVx71 +vzx7hhRHL28/0vBEHzPvHun+wtUMDjlfNnyr2wXzZRb0fB7KAC9r6K15z8Og+dzU +qNrAMmsu1OFVHUUxWnOYE2Svnj6oLMynmHhJqXqREWTNlOOce3pJKzCGdy0hzQAo +zGnFhpcg3Fw6s7+iQHF+lb+cO53Zb3QW2xRgFZBwNd6eEwx9deCA5htPVFW5wbAJ +asud4eSwkFb6M9Hbg6gT67rMMzIrWAbeQwgihIYSJe2v0qMyox6czjvuwZVMHJdH +byBTkkVEmdxTd03V5F21f3wrik/4oWqytjmjvMIY1gGTMo7aBnvPoKpgc2fqJub9 +cdAfGiJnFqo4Ae55mL4sgJPUCP7UATaDNAOCgt0zStmHMH8ACwk0dh1pzjyjpSR3 +OQfFs8QCAl9cvzxwux1tzG/uYxOrr+Rj2JlZKW/ljbWOeE0Gnjca73F40uGkEIbZ +5i6YEuiPE6XGH0TP62Sdu2t5OlaKnZT12Tf6E8xNDsdaLuvAIz5sXyhoxvOmVd9w +V4+uN1bZ10c5k/4uGRsHiXjX6IyYZEj8rKz6ryNikCdi6OzxWE3pCXmfBlVaXtO6 +EIubzk6dgjWcsPoqOsIl5Ywz4RWu0YUk4ZxRts54jCn14bPQpoECggEBAPiLTN8Z +I0GQXMQaq9sN8kVsM/6AG/vWbc+IukPDYEC6Prk79jzkxMpDP8qK9C71bh39U1ky +Kz4gSsLi9v3rM1gZwNshkZJ/zdQJ1NiCkzJVJX48DGeyYqUBjVt8Si37V2vzblBN +RvM7U3rDN0xGiannyWnBC/jed+ZFCo97E9yOxIAs2ekwsl+ED3j1cARv8pBTGWnw +Zhh4AD/Osk5U038oYcWHaIzUuNhEpv46bFLjVT11mGHfUY51Db3jBn0HYRlOPEV/ +F0kE5F+6rRg2tt7n0PO3UbzSNFyDRwtknJ2Nh4EtZZe93domls8SMR/kEHXcPLiQ +ytEFyIAzsxfUwrECggEBANsc54N/LPmX1XuC643ZsDobH5/ALKc8W7wE7e82oSTD +7cKBgdgB71DupJ7m81LHaDgT2RIzjl+lR3VVYLR/ukMcW+47JWrHyrsinu6itOdt +ruhw0UPksoJGsB4KxUdRioFVT7m45GpnseJL0tjYaTCW01swae4QL4skNjjphPrb +b/heMz9n79TK2ePlw1BvJKH0fnOJRuh/v63pD9SymB8EPsazjloKZ5qTrqVi3Obs +F8WTSdl8KB1JSgeppdvHRcZQY1J+UfdCAlGD/pP7/zCKkRYcetre7fGMKVyPIDzO +GAWz0xA2jnrgg7UqIh74oRHe0lZVMdMQ7FoJbRa7KC0CggEAJreEbQh8bn0vhjjl +ZoVApUHaw51vPobDql2RLncj6lFY7gACNrAoW52oNUP6D8qZscBBmJZxGAdtvfgf +I6Tc5a91VG1hQOH5zTsO1f9ZMLEE2yo9gHXQWgXo4ER3RbxufNl56LZxA/jM40W/ +unkOftIllPzGgakeIlfE8l7o1CXFRHY4J9Q3JRvsURpirb5GmeboAZG6RbuDxmzL +Z9pc6+T9fgi+55lHhiEDpnyxXSQepilIaI6iJL/lORxBaX6ZyJhgWS8YEH7bmHH6 +/tefGxAfg6ed6v0PvQ2SJpswrnZakmvg9IdWJOJ4AZ/C2UXsrn91Ugb0ISV2e0oS +bvbssQKCAQBjstc04h0YxJmCxaNgu/iPt9+/1LV8st4awzNwcS8Jh40bv8nQ+7Bk +5vFIzFVTCSDGw2E2Avd5Vb8aCGskNioOd0ztLURtPdNlKu+eLbKayzGW2h6eAeWn +mXpxcP0q4lNfXe4U16g3Mk+iZFXgDThvv3EUQQcyJ3M6oJN7eeXkLwzXuiUfaK+b +52EVbWpdovTMLG+NKp11FQummjF12n2VP11BFFplZe6WSzRgVIenGy4F3Grx5qhq +CvsAWZT6V8XL4rAOzSOGmiZr6N9hfnwzHhm+Md9Ez8L88YWwc/97K1uK3LPg4LIb +/yRuvmkgJolDlFuopMMzArRIk5lrimVRAoIBAQDZmXk/VMA7fsI1/2sgSME0xt1A +jkJZMZSnVD0UDWFkbyK6E5jDnwVUyqBDYe+HJyT4UnPDNCj++BchCQcG0Jih04RM +jwGqxkfTF9K7kfouINSSXPRw/BtHkqMhV/g324mWcifCFVkDQghuslfmey8BKumo +2KPyGnF9Q8CvTSQ0VlK1ZAKRf/zish49PMm7vD1KGkjRPliS3tgAmXPEpwijPGse +4dSUeTfw5wCKAoq9DHjyHdO5fnfkOvA5PMQ4JZAzOCzJak8ET+tw4wB/dBeYiLVi +l00GHLYAr5Nv/WqVnl/VLMd9rOCnLck+pxBNSa6dTrp3FuY00son6hneIvkv +-----END RSA PRIVATE KEY----- diff --git a/glanceclient/tests/unit/var/wildcard-certificate.crt b/glanceclient/tests/unit/var/wildcard-certificate.crt new file mode 100644 index 0000000..a5f0b62 --- /dev/null +++ b/glanceclient/tests/unit/var/wildcard-certificate.crt @@ -0,0 +1,61 @@ +#Certificate: +# Data: +# Version: 1 (0x0) +# Serial Number: 13493453254446411258 (0xbb42603e589dedfa) +# Signature Algorithm: sha1WithRSAEncryption +# Issuer: C=US, ST=CA, L=State1, O=Openstack Test Org, OU=Openstack Test Unit, CN=*.pong.example.com/emailAddress=admin@example.com +# Validity +# Not Before: Aug 21 17:29:18 2013 GMT +# Not After : Jul 28 17:29:18 2113 GMT +# Subject: C=US, ST=CA, L=State1, O=Openstack Test Org, OU=Openstack Test Unit, CN=*.pong.example.com/emailAddress=admin@example.com +# Subject Public Key Info: +# Public Key Algorithm: rsaEncryption +# Public-Key: (4096 bit) +# Modulus: +# 00:d4:bb:3a:c4:a0:06:54:31:23:5d:b0:78:5a:be: +# 45:44:ae:a1:89:86:11:d8:ca:a8:33:b0:4f:f3:e1: +# 46:1e:85:a3:2a:9c:a4:e0:c2:14:34:4f:91:df:dc: +# . +# . +# . +# Exponent: 65537 (0x10001) +# Signature Algorithm: sha1WithRSAEncryption +# 9f:cc:08:5d:19:ee:54:31:a3:57:d7:3c:89:89:c0:69:41:dd: +# 46:f8:73:68:ec:46:b9:fa:f5:df:f6:d9:58:35:d8:53:94:88: +# bd:36:a6:23:9e:0c:0d:89:62:35:91:49:b6:14:f4:43:69:3c: +# . +# . +# . +-----BEGIN CERTIFICATE----- +MIIFyjCCA7ICCQC7QmA+WJ3t+jANBgkqhkiG9w0BAQUFADCBpTELMAkGA1UEBhMC +VVMxCzAJBgNVBAgMAkNBMQ8wDQYDVQQHDAZTdGF0ZTExGzAZBgNVBAoMEk9wZW5z +dGFjayBUZXN0IE9yZzEcMBoGA1UECwwTT3BlbnN0YWNrIFRlc3QgVW5pdDEbMBkG +A1UEAwwSKi5wb25nLmV4YW1wbGUuY29tMSAwHgYJKoZIhvcNAQkBFhFhZG1pbkBl +eGFtcGxlLmNvbTAgFw0xMzA4MjExNzI5MThaGA8yMTEzMDcyODE3MjkxOFowgaUx +CzAJBgNVBAYTAlVTMQswCQYDVQQIDAJDQTEPMA0GA1UEBwwGU3RhdGUxMRswGQYD +VQQKDBJPcGVuc3RhY2sgVGVzdCBPcmcxHDAaBgNVBAsME09wZW5zdGFjayBUZXN0 +IFVuaXQxGzAZBgNVBAMMEioucG9uZy5leGFtcGxlLmNvbTEgMB4GCSqGSIb3DQEJ +ARYRYWRtaW5AZXhhbXBsZS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK +AoICAQDUuzrEoAZUMSNdsHhavkVErqGJhhHYyqgzsE/z4UYehaMqnKTgwhQ0T5Hf +3GmlIBt4I96/3cxj0qSLrdR81fM+5Km8lIlVHwVn1y6LKcMlaUC4K+sgDLcjhZfb +f9+fMkcur3WlNzKpAEaIosWwsu6YvYc+W/nPBpKxMbOZ4fZiPMEo8Pxmw7sl/6hn +lBOJj7dpZOZpHhVPZgzYNVoyfKCZiwgdxH4JEYa+EQos87+2Nwhs7bCgrTLLppCU +vpobwZV5w4O0D6INpUfBmsr4IAuXeFWZa61vZYqhaVbAbTTlUzOLGh7Z2uz9gt75 +iSR2J0e2xntVaUIYLIAUNOO2edk8NMAuIOGr2EIyC7i2O/BTti2YjGNO7SsEClxi +IFKjYahylHmNrS1Q/oMAcJppmhz+oOCmKOMmAZXYAH1A3gs/sWphJpgv/MWt6Ji2 +4VpFaJ+o4bHILlqIpuvL4GLIOkmxVP639khaumgKtgNIUTKJ/V6t/J31WARfxKxl +BQTTzV/Be+84YJiiddx8eunU8AorPyAJFzsDPTJpFUB4Q5BwAeDGCySgxJpUqM2M +TETBycdiVToM4SWkRsOZgZxQ+AVfkkqDct2Bat2lg9epcIez8PrsohQjQbmiqUUL +2c3de4kLYzIWF8EN3P2Me/7b06jbn4c7Fly/AN6tJOG23BzhHQIDAQABMA0GCSqG +SIb3DQEBBQUAA4ICAQCfzAhdGe5UMaNX1zyJicBpQd1G+HNo7Ea5+vXf9tlYNdhT +lIi9NqYjngwNiWI1kUm2FPRDaTwC0kLxk5zBPzF7bcf0SwJCeDjmlUpY7YenS0DA +XmIbg8FvgOlp69Ikrqz98Y4pB9H4O81WdjxNBBbHjrufAXxZYnh5rXrVsXeSJ8jN +MYGWlSv4xwFGfRX53b8VwXFjGjAkH8SQGtRV2w9d0jF8OzFwBA4bKk4EplY0yBPR +2d7Y3RVrDnOVfV13F8CZxJ5fu+6QamUwIaTjpyqflE1L52KTy+vWPYR47H2u2bhD +IeZRufJ8adNIOtH32EcENkusQjLrb3cTXGW00TljhFXd22GqL5d740u+GEKHtWh+ +9OKPTMZK8yK7d5EyS2agTVWmXU6HfpAKz9+AEOnVYErpnggNZjkmJ9kD185rGlSZ +Vvo429hXoUAHNbd+8zda3ufJnJf5q4ZEl8+hp8xsvraUy83XLroVZRsKceldmAM8 +swt6n6w5gRKg4xTH7KFrd+KNptaoY3SsVrnJuaSOPenrUXbZzaI2Q35CId93+8NP +mXVIWdPO1msdZNiCYInRIGycK+oifUZPtAaJdErg8rt8NSpHzYKQ0jfjAGiVHBjK +s0J2TjoKB3jtlrw2DAmFWKeMGNp//1Rm6kfQCCXWftn+TA7XEJhcjyDBVciugA== +-----END CERTIFICATE----- diff --git a/glanceclient/tests/unit/var/wildcard-san-certificate.crt b/glanceclient/tests/unit/var/wildcard-san-certificate.crt new file mode 100644 index 0000000..6ce27bc --- /dev/null +++ b/glanceclient/tests/unit/var/wildcard-san-certificate.crt @@ -0,0 +1,54 @@ +#Certificate: +# Data: +# Version: 3 (0x2) +# Serial Number: 11990626514780340979 (0xa66743493fdcc2f3) +# Signature Algorithm: sha1WithRSAEncryption +# Issuer: C=US, ST=CA, L=State1, O=Openstack Test Org, OU=Openstack Test Unit, CN=0.0.0.0 +# Validity +# Not Before: Dec 10 15:31:22 2013 GMT +# Not After : Nov 16 15:31:22 2113 GMT +# Subject: C=US, ST=CA, L=State1, O=Openstack Test Org, OU=Openstack Test Unit, CN=0.0.0.0 +# Subject Public Key Info: +# Public Key Algorithm: rsaEncryption +# Public-Key: (2048 bit) +# Modulus: +# 00:ca:6b:07:73:53:24:45:74:05:a5:2a:27:bd:3e: +# . +# . +# . +# Exponent: 65537 (0x10001) +# X509v3 extensions: +# X509v3 Key Usage: +# Key Encipherment, Data Encipherment +# X509v3 Extended Key Usage: +# TLS Web Server Authentication +# X509v3 Subject Alternative Name: +# DNS:foo.example.net, DNS:*.example.com +# Signature Algorithm: sha1WithRSAEncryption +# 7e:41:69:da:f4:3c:06:d6:83:c6:f2:db:df:37:f1:ac:fa:f5: +# . +# . +# . +-----BEGIN CERTIFICATE----- +MIIDxDCCAqygAwIBAgIJAKZnQ0k/3MLzMA0GCSqGSIb3DQEBBQUAMHgxCzAJBgNV +BAYTAlVTMQswCQYDVQQIEwJDQTEPMA0GA1UEBxMGU3RhdGUxMRswGQYDVQQKExJP +cGVuc3RhY2sgVGVzdCBPcmcxHDAaBgNVBAsTE09wZW5zdGFjayBUZXN0IFVuaXQx +EDAOBgNVBAMTBzAuMC4wLjAwIBcNMTMxMjEwMTUzMTIyWhgPMjExMzExMTYxNTMx +MjJaMHgxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEPMA0GA1UEBxMGU3RhdGUx +MRswGQYDVQQKExJPcGVuc3RhY2sgVGVzdCBPcmcxHDAaBgNVBAsTE09wZW5zdGFj +ayBUZXN0IFVuaXQxEDAOBgNVBAMTBzAuMC4wLjAwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQDKawdzUyRFdAWlKie9Pn10j7frffN+z1gEMluK2CtDEwv9 +kbD4uS/Kz4dujfTx03mdyNfiMVlOM+YJm/qeLLSdJyFyvZ9Y3WmJ+vT2RGlMMhLd +/wEnMRrTYLL39pwI6z+gyw+4D78Pyv/OXy02IA6WtVEefYSx1vmVngb3pL+iBzhO +8CZXNI6lqrFhh+Hr4iMkYMtY1vTnwezAL6p64E/ZAFNPYCEJlacESTLQ4VZYniHc +QTgnE1czlI1vxlIk1KDXAzUGeeopZecRih9qlTxtOpklqEciQEE+sHtPcvyvdRE9 +Bdyx5rNSALLIcXs0ViJE1RPlw3fjdBoDIOygqvX1AgMBAAGjTzBNMAsGA1UdDwQE +AwIEMDATBgNVHSUEDDAKBggrBgEFBQcDATApBgNVHREEIjAggg9mb28uZXhhbXBs +ZS5uZXSCDSouZXhhbXBsZS5jb20wDQYJKoZIhvcNAQEFBQADggEBAH5Badr0PAbW +g8by29838az69Raul5IkpZQ5V3O1NaNNWxvmF1q8zFFqqGK5ktXJAwGiwnYEBb30 +Zfrr+eFIEERzBthSJkWlP8NG+2ooMyg50femp+asAvW+KYYefJW8KaXTsznMsAFy +z1agcWVYVZ4H9PwunEYn/rM1krLEe4Cagsw5nmf8VqZg+hHtw930q8cRzgDsZdfA +jVK6dWdmzmLCUTL1GKCeNriDw1jIeFvNufC+Q3orH7xBx4VL+NV5ORWdNY/B8q1b +mFHdzbuZX6v39+2ww6aZqG2orfxUocc/5Ox6fXqenKPI3moeHS6Ktesq7sEQSJ6H +QZFsTuT/124= +-----END CERTIFICATE----- diff --git a/glanceclient/tests/utils.py b/glanceclient/tests/utils.py new file mode 100644 index 0000000..a47dcb3 --- /dev/null +++ b/glanceclient/tests/utils.py @@ -0,0 +1,215 @@ +# 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 copy +import json +import six +import six.moves.urllib.parse as urlparse +import testtools + +from glanceclient.v2.schemas import Schema + + +class FakeAPI(object): + def __init__(self, fixtures): + self.fixtures = fixtures + self.calls = [] + + def _request(self, method, url, headers=None, data=None, + content_length=None): + call = build_call_record(method, sort_url_by_query_keys(url), + headers or {}, data) + if content_length is not None: + call = tuple(list(call) + [content_length]) + self.calls.append(call) + + fixture = self.fixtures[sort_url_by_query_keys(url)][method] + + data = fixture[1] + if isinstance(fixture[1], six.string_types): + try: + data = json.loads(fixture[1]) + except ValueError: + data = six.StringIO(fixture[1]) + + return FakeResponse(fixture[0], fixture[1]), data + + def get(self, *args, **kwargs): + return self._request('GET', *args, **kwargs) + + def post(self, *args, **kwargs): + return self._request('POST', *args, **kwargs) + + def put(self, *args, **kwargs): + return self._request('PUT', *args, **kwargs) + + def patch(self, *args, **kwargs): + return self._request('PATCH', *args, **kwargs) + + def delete(self, *args, **kwargs): + return self._request('DELETE', *args, **kwargs) + + def head(self, *args, **kwargs): + return self._request('HEAD', *args, **kwargs) + + +class FakeSchemaAPI(FakeAPI): + def get(self, *args, **kwargs): + _, raw_schema = self._request('GET', *args, **kwargs) + return Schema(raw_schema) + + +class RawRequest(object): + def __init__(self, headers, body=None, + version=1.0, status=200, reason="Ok"): + """ + :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) + + +class FakeResponse(object): + def __init__(self, headers=None, body=None, + version=1.0, status_code=200, reason="Ok"): + """ + :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.reason = reason + self.version = version + self.headers = headers + self.status_code = status_code + self.raw = RawRequest(headers, body=body, reason=reason, + version=version, status=status_code) + + @property + def ok(self): + return (self.status_code < 400 or + self.status_code >= 600) + + def read(self, amt): + return self.body.read(amt) + + def close(self): + pass + + @property + def content(self): + if hasattr(self.body, "read"): + return self.body.read() + return self.body + + @property + def text(self): + if isinstance(self.content, six.binary_type): + return self.content.decode('utf-8') + + return self.content + + def json(self, **kwargs): + return self.body and json.loads(self.text) or "" + + def iter_content(self, chunk_size=1, decode_unicode=False): + while True: + chunk = self.raw.read(chunk_size) + if not chunk: + break + yield chunk + + +class TestCase(testtools.TestCase): + TEST_REQUEST_BASE = { + 'config': {'danger_mode': False}, + 'verify': True} + + +class FakeTTYStdout(six.StringIO): + """A Fake stdout that try to emulate a TTY device as much as possible.""" + + def isatty(self): + return True + + def write(self, data): + # When a CR (carriage return) is found reset file. + if data.startswith('\r'): + self.seek(0) + data = data[1:] + return six.StringIO.write(self, data) + + +class FakeNoTTYStdout(FakeTTYStdout): + """A Fake stdout that is not a TTY device.""" + + def isatty(self): + return False + + +def sort_url_by_query_keys(url): + """A helper function which sorts the keys of the query string of a url. + For example, an input of '/v2/tasks?sort_key=id&sort_dir=asc&limit=10' + returns '/v2/tasks?limit=10&sort_dir=asc&sort_key=id'. This is to + prevent non-deterministic ordering of the query string causing + problems with unit tests. + :param url: url which will be ordered by query keys + :returns url: url with ordered query keys + """ + parsed = urlparse.urlparse(url) + queries = urlparse.parse_qsl(parsed.query, True) + sorted_query = sorted(queries, key=lambda x: x[0]) + + encoded_sorted_query = urlparse.urlencode(sorted_query, True) + + url_parts = (parsed.scheme, parsed.netloc, parsed.path, + parsed.params, encoded_sorted_query, + parsed.fragment) + + return urlparse.urlunparse(url_parts) + + +def build_call_record(method, url, headers, data): + """Key the request body be ordered if it's a dict type. + """ + if isinstance(data, dict): + data = sorted(data.items()) + if isinstance(data, six.string_types): + # NOTE(flwang): For image update, the data will be a 'list' which + # contains operation dict, such as: [{"op": "remove", "path": "/a"}] + try: + data = json.loads(data) + except ValueError: + return (method, url, headers or {}, data) + data = [sorted(d.items()) for d in data] + return (method, url, headers or {}, data) |