Merge "Migrate the recordset validation functional test"
diff --git a/README.rst b/README.rst
index 87f4736..09d8191 100644
--- a/README.rst
+++ b/README.rst
@@ -1,3 +1,12 @@
+========================
+Team and repository tags
+========================
+
+.. image:: http://governance.openstack.org/badges/designate-tempest-plugin.svg
+    :target: http://governance.openstack.org/reference/tags/index.html
+
+.. Change things from this point on
+
 ================================
 Tempest Integration of Designate
 ================================
diff --git a/designate_tempest_plugin/clients.py b/designate_tempest_plugin/clients.py
index d85d343..87b43c4 100644
--- a/designate_tempest_plugin/clients.py
+++ b/designate_tempest_plugin/clients.py
@@ -13,9 +13,7 @@
 # under the License.
 from tempest import clients
 from tempest import config
-from tempest.lib.auth import KeystoneAuthProvider
-from tempest.lib.auth import KeystoneV2AuthProvider
-from tempest.lib.auth import KeystoneV3AuthProvider
+from tempest.lib import auth
 
 from designate_tempest_plugin.services.dns.v1.json.domains_client import \
     DomainsClient
@@ -55,8 +53,8 @@
 
 class ManagerV1(clients.Manager):
 
-    def __init__(self, credentials=None, service=None):
-        super(ManagerV1, self).__init__(credentials, service)
+    def __init__(self, credentials=None):
+        super(ManagerV1, self).__init__(credentials)
         self._init_clients(self._get_params())
 
     def _init_clients(self, params):
@@ -79,8 +77,8 @@
 
 class ManagerV2(clients.Manager):
 
-    def __init__(self, credentials=None, service=None):
-        super(ManagerV2, self).__init__(credentials, service)
+    def __init__(self, credentials=None):
+        super(ManagerV2, self).__init__(credentials)
         self._init_clients(self._get_params())
 
     def _init_clients(self, params):
@@ -118,8 +116,8 @@
 
 class ManagerAdmin(clients.Manager):
 
-    def __init__(self, credentials=None, service=None):
-        super(ManagerAdmin, self).__init__(credentials, service)
+    def __init__(self, credentials=None):
+        super(ManagerAdmin, self).__init__(credentials)
         self._init_clients(self._get_params())
 
     def _init_clients(self, params):
@@ -140,8 +138,8 @@
 
 class ManagerV2Unauthed(ManagerV2):
 
-    def __init__(self, credentials=None, service=None):
-        super(ManagerV2Unauthed, self).__init__(credentials, service)
+    def __init__(self, credentials=None):
+        super(ManagerV2Unauthed, self).__init__(credentials)
         self.auth_provider = self._auth_provider_class()(
             credentials=self.auth_provider.credentials,
             auth_url=self.auth_provider.auth_client.auth_url,
@@ -158,7 +156,7 @@
             return KeystoneV2UnauthedProvider
 
 
-class BaseUnauthedProvider(KeystoneAuthProvider):
+class BaseUnauthedProvider(auth.KeystoneAuthProvider):
 
     def _decorate_request(self, filters, method, url, headers=None, body=None,
                           auth_data=None):
@@ -173,13 +171,15 @@
         return url, headers, body
 
 
-class KeystoneV2UnauthedProvider(KeystoneV2AuthProvider, BaseUnauthedProvider):
+class KeystoneV2UnauthedProvider(auth.KeystoneV2AuthProvider,
+                                 BaseUnauthedProvider):
 
     def _decorate_request(self, *args, **kwargs):
         return BaseUnauthedProvider._decorate_request(self, *args, **kwargs)
 
 
-class KeystoneV3UnauthedProvider(KeystoneV3AuthProvider, BaseUnauthedProvider):
+class KeystoneV3UnauthedProvider(auth.KeystoneV3AuthProvider,
+                                 BaseUnauthedProvider):
 
     def _decorate_request(self, *args, **kwargs):
         return BaseUnauthedProvider._decorate_request(self, *args, **kwargs)
diff --git a/designate_tempest_plugin/common/models.py b/designate_tempest_plugin/common/models.py
index e69bd09..b2a1687 100644
--- a/designate_tempest_plugin/common/models.py
+++ b/designate_tempest_plugin/common/models.py
@@ -31,6 +31,9 @@
     def __eq__(self, other):
         return self.__dict__ == other.__dict__
 
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
     @classmethod
     def from_text(cls, text):
         """Return a ZoneFile from a string containing the zone file contents"""
@@ -64,6 +67,9 @@
     def __eq__(self, other):
         return self.__dict__ == other.__dict__
 
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
     def __hash__(self):
         return hash(tuple(sorted(self.__dict__.items())))
 
diff --git a/designate_tempest_plugin/hacking/__init__.py b/designate_tempest_plugin/hacking/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/designate_tempest_plugin/hacking/__init__.py
diff --git a/designate_tempest_plugin/hacking/checks.py b/designate_tempest_plugin/hacking/checks.py
new file mode 100644
index 0000000..366aca3
--- /dev/null
+++ b/designate_tempest_plugin/hacking/checks.py
@@ -0,0 +1,31 @@
+# Copyright 2016 Hewlett Packard Enterprise Development Company, L.P.
+#
+# 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 tempest.hacking import checks
+
+
+def factory(register):
+    # Imported from Tempest
+    register(checks.import_no_clients_in_api_and_scenario_tests)
+    register(checks.scenario_tests_need_service_tags)
+    register(checks.no_setup_teardown_class_for_tests)
+    register(checks.no_vi_headers)
+    register(checks.service_tags_not_in_module_path)
+    register(checks.no_hyphen_at_end_of_rand_name)
+    register(checks.no_mutable_default_args)
+    register(checks.no_testtools_skip_decorator)
+    register(checks.get_resources_on_service_clients)
+    register(checks.delete_resources_on_service_clients)
+    register(checks.dont_use_config_in_tempest_lib)
+    register(checks.use_rand_uuid_instead_of_uuid4)
diff --git a/designate_tempest_plugin/services/dns/admin/json/quotas_client.py b/designate_tempest_plugin/services/dns/admin/json/quotas_client.py
index 08b1790..fb233d2 100644
--- a/designate_tempest_plugin/services/dns/admin/json/quotas_client.py
+++ b/designate_tempest_plugin/services/dns/admin/json/quotas_client.py
@@ -11,13 +11,10 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations
 # under the License.
-from oslo_log import log as logging
 
 from designate_tempest_plugin import data_utils as dns_data_utils
 from designate_tempest_plugin.services.dns.admin.json import base
 
-LOG = logging.getLogger(__name__)
-
 
 class QuotasClient(base.DnsClientAdminBase):
 
diff --git a/designate_tempest_plugin/services/dns/json/base.py b/designate_tempest_plugin/services/dns/json/base.py
index 19dbe45..6c5f1d2 100644
--- a/designate_tempest_plugin/services/dns/json/base.py
+++ b/designate_tempest_plugin/services/dns/json/base.py
@@ -20,7 +20,7 @@
 from six.moves.urllib import parse as urllib
 import six
 
-from designate_tempest_plugin.common.models import ZoneFile
+from designate_tempest_plugin.common import models
 
 LOG = logging.getLogger(__name__)
 
@@ -66,7 +66,7 @@
         if 'application/json' in resp['content-type']:
             return json.loads(object_str)
         elif 'text/dns' in resp['content-type']:
-            return ZoneFile.from_text(object_str)
+            return models.ZoneFile.from_text(object_str)
         else:
             raise lib_exc.InvalidContentType()
 
diff --git a/designate_tempest_plugin/services/dns/v2/json/quotas_client.py b/designate_tempest_plugin/services/dns/v2/json/quotas_client.py
index 3522a1d..df2cdee 100644
--- a/designate_tempest_plugin/services/dns/v2/json/quotas_client.py
+++ b/designate_tempest_plugin/services/dns/v2/json/quotas_client.py
@@ -11,13 +11,10 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations
 # under the License.
-from oslo_log import log as logging
 
 from designate_tempest_plugin import data_utils as dns_data_utils
 from designate_tempest_plugin.services.dns.v2.json import base
 
-LOG = logging.getLogger(__name__)
-
 
 class QuotasClient(base.DnsClientV2Base):
 
diff --git a/designate_tempest_plugin/services/dns/v2/json/recordset_client.py b/designate_tempest_plugin/services/dns/v2/json/recordset_client.py
index 3363e0c..578ccf3 100644
--- a/designate_tempest_plugin/services/dns/v2/json/recordset_client.py
+++ b/designate_tempest_plugin/services/dns/v2/json/recordset_client.py
@@ -26,7 +26,7 @@
 
         :param zone_uuid: Unique identifier of the zone in UUID format..
         :param recordset_data: A dictionary that represents the recordset
-                                data.
+                               data.
         :param params: A Python dict that represents the query paramaters to
                        include in the request URI.
         :return: A tuple with the server response and the created zone.
@@ -44,6 +44,29 @@
         return resp, body
 
     @base.handle_errors
+    def update_recordset(self, zone_uuid, recordset_uuid,
+                         recordet_data, params=None):
+        """Update the recordset related to the specified zone.
+        :param zone_uuid: Unique identifier of the zone in UUID format.
+        :param recordset_uuid: Unique identifier of the recordset in UUID
+                               format.
+        :param recordset_data: A dictionary that represents the recordset
+                               data.
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :return: A tuple with the server response and the created zone.
+        """
+        resp, body = self._put_request(
+            'zones/{0}/recordsets'.format(zone_uuid), recordset_uuid,
+            data=recordet_data, params=params)
+
+        # Update Recordset should Return a HTTP 202, or a 200 if the recordset
+        # is already active
+        self.expected_success([200, 202], resp.status)
+
+        return resp, body
+
+    @base.handle_errors
     def show_recordset(self, zone_uuid, recordset_uuid, params=None):
         """Gets a specific recordset related to a specific zone.
         :param zone_uuid: Unique identifier of the zone in UUID format.
@@ -87,6 +110,24 @@
             'zones/{0}/recordsets'.format(uuid), params=params)
 
     @base.handle_errors
+    def show_zones_recordset(self, recordset_uuid, params=None):
+        """Gets a single recordset, using the cross_zone endpoint
+        :param recordset_uuid: Unique identifier of the recordset in UUID
+                               format.
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :return: A tuple with the server response and the response body.
+        """
+        resp, body = self._show_request(
+            'recordsets', recordset_uuid,
+            params=params)
+
+        # Show recordsets/id should return a HTTP 301
+        self.expected_success(301, resp.status)
+
+        return resp, body
+
+    @base.handle_errors
     def list_zones_recordsets(self, params=None):
         """List recordsets across all zones.
         :param params: A Python dict that represents the query paramaters to
@@ -95,23 +136,3 @@
         """
         return self._list_request(
             'recordsets', params=params)
-
-    @base.handle_errors
-    def update_recordset(self, zone_uuid, recordset_uuid,
-                         recordset_model, params=None):
-        """Update the recordset related to the specified zone.
-        :param zone_uuid: Unique identifier of the zone in UUID format..
-        :param recordset_uuid: Unique identifier of the records in UUID format.
-        :param recordset_model: .
-        :param params: A Python dict that represents the query paramaters to
-                       include in the request URI.
-        :return: A tuple with the server response and the created zone.
-        """
-        resp, body = self._put_request(
-            'zones/{0}/recordsets'.format(zone_uuid), recordset_uuid,
-            data=recordset_model, params=params)
-
-        # Update Recordset should Return a HTTP 202
-        self.expected_success(202, resp.status)
-
-        return resp, body
diff --git a/designate_tempest_plugin/services/dns/v2/json/transfer_request_client.py b/designate_tempest_plugin/services/dns/v2/json/transfer_request_client.py
index 513a8ad..f494cde 100644
--- a/designate_tempest_plugin/services/dns/v2/json/transfer_request_client.py
+++ b/designate_tempest_plugin/services/dns/v2/json/transfer_request_client.py
@@ -42,6 +42,24 @@
         return resp, body
 
     @base.handle_errors
+    def create_transfer_request_empty_body(self, uuid, params=None):
+        """Create a zone transfer_requests.
+        :param uuid: Unique identifier of the zone in UUID format.
+        :param params: A Python dict that represents the query paramaters to
+                       include in the request URI.
+        :return: Serialized zone trasfer request as a dictionary.
+        """
+
+        transfer_request_uri = 'zones/{0}/tasks/transfer_requests'.format(uuid)
+        resp, body = self._create_request(
+            transfer_request_uri, None, params=params)
+
+        # Create Transfer request should Return a HTTP 201
+        self.expected_success(201, resp.status)
+
+        return resp, body
+
+    @base.handle_errors
     def show_transfer_request(self, uuid, params=None):
         """Gets a specific transfer_requestsed zone.
         :param uuid: Unique identifier of the transfer_requestsed zone in
diff --git a/designate_tempest_plugin/tests/api/v1/test_domains.py b/designate_tempest_plugin/tests/api/v1/test_domains.py
index 84e77e5..9c6d1b6 100644
--- a/designate_tempest_plugin/tests/api/v1/test_domains.py
+++ b/designate_tempest_plugin/tests/api/v1/test_domains.py
@@ -31,8 +31,8 @@
         cls.client = cls.os.domains_client
 
     @classmethod
-    def setUpClass(cls):
-        super(DnsDomainsTest, cls).setUpClass()
+    def resource_setup(cls):
+        super(DnsDomainsTest, cls).resource_setup()
         cls.setup_domains = list()
         for i in range(2):
             name = data_utils.rand_name('domain') + '.com.'
@@ -41,10 +41,10 @@
             cls.setup_domains.append(domain)
 
     @classmethod
-    def tearDownClass(cls):
+    def resource_cleanup(cls):
         for domain in cls.setup_domains:
             cls.client.delete_domain(domain['id'])
-        super(DnsDomainsTest, cls).tearDownClass()
+        super(DnsDomainsTest, cls).resource_cleanup()
 
     def _delete_domain(self, domain_id):
         self.client.delete_domain(domain_id)
diff --git a/designate_tempest_plugin/tests/api/v1/test_records.py b/designate_tempest_plugin/tests/api/v1/test_records.py
index 1e395d6..ec15771 100644
--- a/designate_tempest_plugin/tests/api/v1/test_records.py
+++ b/designate_tempest_plugin/tests/api/v1/test_records.py
@@ -30,8 +30,8 @@
         cls.client = cls.os.records_client
 
     @classmethod
-    def setUpClass(cls):
-        super(RecordsTest, cls).setUpClass()
+    def resource_setup(cls):
+        super(RecordsTest, cls).resource_setup()
 
         # Creates domains and Records for testcase
         cls.setup_records = list()
@@ -53,11 +53,11 @@
         cls.setup_records.append(record)
 
     @classmethod
-    def tearDownClass(cls):
+    def resource_cleanup(cls):
         for record in cls.setup_records:
             cls.client.delete_record(cls.domain['id'], record['id'])
         cls.os.domains_client.delete_domain(cls.domain['id'])
-        super(RecordsTest, cls).tearDownClass()
+        super(RecordsTest, cls).resource_cleanup()
 
     def _delete_record(self, domain_id, record_id):
         self.client.delete_record(domain_id, record_id)
diff --git a/designate_tempest_plugin/tests/api/v1/test_servers.py b/designate_tempest_plugin/tests/api/v1/test_servers.py
index 74639ba..c92c604 100644
--- a/designate_tempest_plugin/tests/api/v1/test_servers.py
+++ b/designate_tempest_plugin/tests/api/v1/test_servers.py
@@ -48,8 +48,8 @@
             raise cls.skipException(skip_msg)
 
     @classmethod
-    def setUpClass(cls):
-        super(ServersAdminTest, cls).setUpClass()
+    def resource_setup(cls):
+        super(ServersAdminTest, cls).resource_setup()
 
         cls.setup_servers = list()
         for i in range(2):
@@ -58,10 +58,10 @@
             cls.setup_servers.append(server)
 
     @classmethod
-    def tearDownClass(cls):
+    def resource_cleanup(cls):
         for server in cls.setup_servers:
             cls.client.delete_server(server['id'])
-        super(ServersAdminTest, cls).tearDownClass()
+        super(ServersAdminTest, cls).resource_cleanup()
 
     def _delete_server(self, server_id):
         self.client.delete_server(server_id)
diff --git a/designate_tempest_plugin/tests/api/v2/recordset_wildcard_data.json b/designate_tempest_plugin/tests/api/v2/recordset_wildcard_data.json
new file mode 100644
index 0000000..dedf414
--- /dev/null
+++ b/designate_tempest_plugin/tests/api/v2/recordset_wildcard_data.json
@@ -0,0 +1,53 @@
+{
+    "A at APEX": {
+        "name": "*",
+        "type": "A",
+        "records": ["192.0.2.1", "192.0.2.2", "192.0.2.3"]
+    },
+    "A under APEX": {
+        "name": "*.sub",
+        "type": "A",
+        "records": ["192.0.2.1", "192.0.2.2", "192.0.2.3"]
+    },
+    "AAAA at APEX": {
+        "name": "*",
+        "type": "AAAA",
+        "records": ["2001:db8::1", "2001:db8::1", "2001:db8::"]
+    },
+    "AAAA under APEX": {
+        "name": "*.sub",
+        "type": "AAAA",
+        "records": ["2001:db8::1", "2001:db8::1", "2001:db8::"]
+    },
+    "MX at APEX": {
+        "name": "*",
+        "type": "MX",
+        "records": ["10 mail1.example.org.",
+                    "20 mail2.example.org."]
+    },
+    "MX under APEX": {
+        "name": "*.sub",
+        "type": "MX",
+        "records": ["10 mail.example.org."]
+    },
+    "SPF at APEX": {
+        "name": "*",
+        "type": "SPF",
+        "records": ["v=spf1; a -all"]
+    },
+    "SPF under APEX": {
+        "name": "*.sub",
+        "type": "SPF",
+        "records": ["v=spf1; a -all"]
+    },
+    "TXT at APEX": {
+        "name": "*",
+        "type": "TXT",
+        "records": ["Can you read me?"]
+    },
+    "TXT under APEX": {
+        "name": "*.sub",
+        "type": "TXT",
+        "records": ["Can you read me?"]
+    }
+}
diff --git a/designate_tempest_plugin/tests/api/v2/test_quotas.py b/designate_tempest_plugin/tests/api/v2/test_quotas.py
index 9411270..bd865bb 100644
--- a/designate_tempest_plugin/tests/api/v2/test_quotas.py
+++ b/designate_tempest_plugin/tests/api/v2/test_quotas.py
@@ -75,7 +75,7 @@
         LOG.info("Ensuring the response has all quota types")
         self.assertExpected(quotas, body, [])
 
-    @decorators.idempotent_id('76d24c87-1b39-4e19-947c-c08e1380dc61')
+    @decorators.idempotent_id('9b09b3e2-7e88-4569-bce3-9be2f7ac70c3')
     def test_update_quotas_other_project(self):
 
         project_id = self.quotas_client.tenant_id
diff --git a/designate_tempest_plugin/tests/api/v2/test_recordset.py b/designate_tempest_plugin/tests/api/v2/test_recordset.py
index 9096f76..d686d94 100644
--- a/designate_tempest_plugin/tests/api/v2/test_recordset.py
+++ b/designate_tempest_plugin/tests/api/v2/test_recordset.py
@@ -91,6 +91,28 @@
         LOG.info('Ensure we respond with PENDING')
         self.assertEqual('PENDING', body['status'])
 
+    @decorators.idempotent_id('69f002e5-6511-43d3-abae-7abdd45ae03e')
+    @ddt.file_data("recordset_wildcard_data.json")
+    def test_create_wildcard_recordset(self, name, type, records):
+        if name is not None:
+            recordset_name = name + "." + self.zone['name']
+
+        else:
+            recordset_name = "*." + self.zone['name']
+
+        recordset_data = {
+            'name': recordset_name,
+            'type': type,
+            'records': records,
+        }
+
+        LOG.info('Create a Recordset')
+        resp, body = self.client.create_recordset(
+            self.zone['id'], recordset_data)
+
+        LOG.info('Ensure we respond with PENDING')
+        self.assertEqual('PENDING', body['status'])
+
     @decorators.idempotent_id('5964f730-5546-46e6-9105-5030e9c492b2')
     def test_list_recordsets(self):
         recordset_data = data_utils.rand_recordset_data(
@@ -187,7 +209,7 @@
         cls.client = cls.os.recordset_client
         cls.zone_client = cls.os.zones_client
 
-    @decorators.idempotent_id('631d74fd-6909-4684-a61b-5c4d2f92c3e7')
+    @decorators.idempotent_id('98c94f8c-217a-4056-b996-b1f856d0753e')
     @ddt.file_data("recordset_data_invalid.json")
     def test_create_recordset_invalid(self, name, type, records):
         if name is not None:
@@ -303,17 +325,22 @@
 
         self.assertGreater(len(body['recordsets']), 0)
 
+    @decorators.skip_because(bug="1616892")
+    @decorators.idempotent_id('65ec0495-81d9-4cfb-8007-9d93b32ae883')
+    def test_get_single_zones_recordsets(self):
+        recordset_data = data_utils.rand_recordset_data(
+            record_type='A', zone_name=self.zone['name'], records=['10.1.0.2'])
+
+        LOG.info('Create a Recordset')
+        resp, zone_recordset = self.client.create_recordset(
+            self.zone['id'], recordset_data)
+
+        self.client.show_zones_recordset(zone_recordset['id'])
+
     @decorators.idempotent_id('a8e41020-65be-453b-a8c1-2497d539c345')
     def test_list_filter_zones_recordsets(self):
-        recordset_data = {
-            "name": self.zone['name'],
-            "description": "This is an example record set.",
-            "type": "A",
-            "ttl": 3600,
-            "records": [
-                "10.1.0.2"
-            ]
-        }
+        recordset_data = data_utils.rand_recordset_data(
+            record_type='A', zone_name=self.zone['name'], records=['10.0.1.2'])
 
         LOG.info('Create a Recordset')
         resp, zone_recordset = self.client.create_recordset(
@@ -323,12 +350,44 @@
         _, zone2 = self.zone_client.create_zone()
         self.addCleanup(self.zone_client.delete_zone, zone2['id'])
 
+        LOG.info('Create another Recordset')
+        recordset_data = data_utils.rand_recordset_data(
+            record_type='A', zone_name=zone2['name'],
+            records=['10.0.1.3'])
+        resp, zone_recordset2 = self.client.create_recordset(
+            zone2['id'], recordset_data)
+
         LOG.info('List recordsets')
-        _, body = self.client.list_zones_recordsets(params={"data": "10.1.*"})
+        _, body = self.client.list_zones_recordsets(params={"data": "10.0.*"})
 
         recordsets = body['recordsets']
 
-        self.assertEqual(zone_recordset['id'], recordsets[0]['id'])
+        ids = [r['id'] for r in recordsets]
+        self.assertIn(zone_recordset['id'], ids)
+        self.assertIn(zone_recordset2['id'], ids)
+        # Ensure that every rrset has a record with the filtered data
+        for r in recordsets:
+            one_record_has_data = False
+            for record in r['records']:
+                if record.startswith('10.0.'):
+                    one_record_has_data = True
+            self.assertTrue(one_record_has_data)
+
+    @decorators.idempotent_id('7f4970bf-9aeb-4a3c-9afd-02f5a7178d35')
+    def test_list_zones_recordsets_zone_names(self):
+        LOG.info('Create another zone')
+        _, zone2 = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone2['id'])
+
+        LOG.info('List recordsets')
+        _, body = self.client.list_zones_recordsets()
+
+        recordsets = body['recordsets']
+        zone_names = set()
+        for r in recordsets:
+            zone_names.add(r['zone_name'])
+
+        self.assertGreaterEqual(len(zone_names), 2)
 
 
 class RecordsetOwnershipTest(BaseRecordsetsTest):
diff --git a/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py b/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py
index a043137..0972a24 100644
--- a/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py
+++ b/designate_tempest_plugin/tests/api/v2/test_transfer_accepts.py
@@ -68,8 +68,8 @@
                         transfer_request['id'])
 
         data = {
-                 "key": transfer_request['key'],
-                 "zone_transfer_request_id": transfer_request['id']
+            "key": transfer_request['key'],
+            "zone_transfer_request_id": transfer_request['id']
         }
 
         LOG.info('Create a zone transfer_accept')
diff --git a/designate_tempest_plugin/tests/api/v2/test_transfer_request.py b/designate_tempest_plugin/tests/api/v2/test_transfer_request.py
index 9bc3897..af81cfc 100644
--- a/designate_tempest_plugin/tests/api/v2/test_transfer_request.py
+++ b/designate_tempest_plugin/tests/api/v2/test_transfer_request.py
@@ -17,22 +17,25 @@
 from tempest.lib import exceptions as lib_exc
 
 from designate_tempest_plugin.tests import base
+from designate_tempest_plugin import data_utils as dns_data_utils
 
 LOG = logging.getLogger(__name__)
 
 
 class BaseTransferRequestTest(base.BaseDnsV2Test):
-    excluded_keys = ['created_at', 'updated_at', 'key', 'links',
-                    'zone_name']
+    excluded_keys = ['created_at', 'updated_at', 'key', 'links']
 
 
 class TransferRequestTest(BaseTransferRequestTest):
+    credentials = ['primary', 'alt']
+
     @classmethod
     def setup_clients(cls):
         super(TransferRequestTest, cls).setup_clients()
 
         cls.zone_client = cls.os.zones_client
         cls.client = cls.os.transfer_request_client
+        cls.alt_client = cls.os_alt.transfer_request_client
 
     @decorators.idempotent_id('2381d489-ad84-403d-b0a2-8b77e4e966bf')
     def test_create_transfer_request(self):
@@ -48,6 +51,38 @@
         LOG.info('Ensure we respond with ACTIVE status')
         self.assertEqual('ACTIVE', transfer_request['status'])
 
+    @decorators.idempotent_id('5deae1ac-7c14-42dc-b14e-4e4b2725beb7')
+    def test_create_transfer_request_scoped(self):
+        LOG.info('Create a zone')
+        _, zone = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone['id'])
+
+        transfer_request_data = dns_data_utils.rand_transfer_request_data(
+            target_project_id=self.os_alt.credentials.project_id)
+
+        LOG.info('Create a scoped zone transfer_request')
+        _, transfer_request = self.client.create_transfer_request(
+            zone['id'], transfer_request_data)
+        self.addCleanup(self.client.delete_transfer_request,
+                        transfer_request['id'])
+
+        LOG.info('Ensure we respond with ACTIVE status')
+        self.assertEqual('ACTIVE', transfer_request['status'])
+
+    @decorators.idempotent_id('4505152f-0a9c-4f02-b385-2216c914a0be')
+    def test_create_transfer_request_empty_body(self):
+        LOG.info('Create a zone')
+        _, zone = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone['id'])
+        LOG.info('Create a zone transfer_request')
+        _, transfer_request = self.client.create_transfer_request_empty_body(
+            zone['id'])
+        self.addCleanup(self.client.delete_transfer_request,
+                        transfer_request['id'])
+
+        LOG.info('Ensure we respond with ACTIVE status')
+        self.assertEqual('ACTIVE', transfer_request['status'])
+
     @decorators.idempotent_id('64a7be9f-8371-4ce1-a242-c1190de7c985')
     def test_show_transfer_request(self):
         LOG.info('Create a zone')
@@ -66,6 +101,32 @@
                  'created transfer_request')
         self.assertExpected(transfer_request, body, self.excluded_keys)
 
+    @decorators.idempotent_id('235ded87-0c47-430b-8cad-4f3194b927a6')
+    def test_show_transfer_request_as_target(self):
+        # Checks the target of a scoped transfer request can see
+        # the request.
+        LOG.info('Create a zone')
+        _, zone = self.zone_client.create_zone()
+        self.addCleanup(self.zone_client.delete_zone, zone['id'])
+
+        transfer_request_data = dns_data_utils.rand_transfer_request_data(
+            target_project_id=self.os_alt.credentials.project_id)
+
+        LOG.info('Create a scoped zone transfer_request')
+        _, transfer_request = self.client.create_transfer_request(
+            zone['id'], transfer_request_data)
+        self.addCleanup(self.client.delete_transfer_request,
+                        transfer_request['id'])
+
+        LOG.info('Fetch the transfer_request as the target')
+        _, body = self.alt_client.show_transfer_request(transfer_request['id'])
+
+        LOG.info('Ensure the fetched response matches the '
+                 'created transfer_request')
+        excluded_keys = self.excluded_keys + ["target_project_id",
+                                              "project_id"]
+        self.assertExpected(transfer_request, body, excluded_keys)
+
     @decorators.idempotent_id('7d81c487-aa15-44c4-b3e5-424ab9e6a3e5')
     def test_delete_transfer_request(self):
         LOG.info('Create a zone')
diff --git a/designate_tempest_plugin/tests/api/v2/test_unauthed.py b/designate_tempest_plugin/tests/api/v2/test_unauthed.py
index e788a3b..b48cfd7 100644
--- a/designate_tempest_plugin/tests/api/v2/test_unauthed.py
+++ b/designate_tempest_plugin/tests/api/v2/test_unauthed.py
@@ -17,7 +17,7 @@
 import ddt
 
 from designate_tempest_plugin.tests import base
-from designate_tempest_plugin.clients import ManagerV2Unauthed
+from designate_tempest_plugin import clients
 
 LOG = logging.getLogger(__name__)
 
@@ -25,7 +25,7 @@
 @ddt.ddt
 class TestDnsUnauthed(base.BaseDnsV2Test):
 
-    client_manager = ManagerV2Unauthed
+    client_manager = clients.ManagerV2Unauthed
     credentials = ["primary"]
 
     @classmethod
diff --git a/designate_tempest_plugin/tests/scenario/v2/test_zones.py b/designate_tempest_plugin/tests/scenario/v2/test_zones.py
index c98a551..ccbfc6c 100644
--- a/designate_tempest_plugin/tests/scenario/v2/test_zones.py
+++ b/designate_tempest_plugin/tests/scenario/v2/test_zones.py
@@ -87,6 +87,7 @@
         waiters.wait_for_zone_404(self.client, zone['id'])
 
     @test.attr(type='slow')
+    @decorators.skip_because(bug='1623576')
     @decorators.idempotent_id('ad8d1f5b-da66-46a0-bbee-14dc84a5d791')
     @config.skip_unless_config('dns', 'nameservers')
     def test_zone_create_propagates_to_nameservers(self):
@@ -98,6 +99,7 @@
         waiters.wait_for_query(self.query_client, zone['name'], "SOA")
 
     @test.attr(type='slow')
+    @decorators.skip_because(bug='1623576')
     @decorators.idempotent_id('d13d3095-c78f-4aae-8fe3-a74ccc335c84')
     @config.skip_unless_config('dns', 'nameservers')
     def test_zone_delete_propagates_to_nameservers(self):
diff --git a/designate_tempest_plugin/tests/scenario/v2/test_zones_transfer.py b/designate_tempest_plugin/tests/scenario/v2/test_zones_transfer.py
new file mode 100644
index 0000000..4870307
--- /dev/null
+++ b/designate_tempest_plugin/tests/scenario/v2/test_zones_transfer.py
@@ -0,0 +1,62 @@
+# Copyright 2016 Rackspace
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+from oslo_log import log as logging
+from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
+
+from designate_tempest_plugin.tests import base
+
+LOG = logging.getLogger(__name__)
+
+
+class ZonesTransferTest(base.BaseDnsV2Test):
+    credentials = ['primary', 'alt']
+
+    @classmethod
+    def setup_clients(cls):
+        super(ZonesTransferTest, cls).setup_clients()
+        cls.zones_client = cls.os.zones_client
+        cls.alt_zones_client = cls.os_alt.zones_client
+        cls.request_client = cls.os.transfer_request_client
+        cls.alt_request_client = cls.os_alt.transfer_request_client
+        cls.accept_client = cls.os.transfer_accept_client
+        cls.alt_accept_client = cls.os_alt.transfer_accept_client
+
+    @decorators.idempotent_id('60bd80ac-c979-4686-9a03-f2f775f272ab')
+    def test_zone_transfer(self):
+        LOG.info('Create a zone as primary tenant')
+        _, zone = self.zones_client.create_zone()
+        self.addCleanup(self.zones_client.delete_zone, zone['id'],
+                        ignore_errors=lib_exc.NotFound)
+        self.addCleanup(self.alt_zones_client.delete_zone, zone['id'],
+                        ignore_errors=lib_exc.NotFound)
+
+        LOG.info('Create a zone transfer_request for zone as primary tenant')
+        _, transfer_request = \
+            self.request_client.create_transfer_request_empty_body(zone['id'])
+
+        accept_data = {
+                 "key": transfer_request['key'],
+                 "zone_transfer_request_id": transfer_request['id']
+        }
+
+        LOG.info('Accept the request as alt tenant')
+        self.alt_accept_client.create_transfer_accept(accept_data)
+
+        LOG.info('Fetch the zone as alt tenant')
+        self.alt_zones_client.show_zone(zone['id'])
+
+        LOG.info('Ensure 404 when fetching the zone as primary tenant')
+        self.assertRaises(lib_exc.NotFound,
+            lambda: self.zones_client.show_zone(zone['id']))
diff --git a/requirements.txt b/requirements.txt
index d7c3d83..13d92fc 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@
 # of appearance. Changing the order has an impact on the overall integration
 # process, which may cause wedges in the gate later.
 
-dnspython>=1.14.0 # http://www.dnspython.org/LICENSE
-dnspython3>=1.12.0;python_version>='3.0' # http://www.dnspython.org/LICENSE
+dnspython>=1.14.0;python_version=='2.7' # http://www.dnspython.org/LICENSE
+dnspython3!=1.13.0,!=1.14.0,>=1.12.0;python_version>='3.0' # http://www.dnspython.org/LICENSE
 ddt>=1.0.1 # MIT
 tempest>=12.1.0 # Apache-2.0
diff --git a/tools/tox_install.sh b/tools/tox_install.sh
new file mode 100755
index 0000000..e61b63a
--- /dev/null
+++ b/tools/tox_install.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+# Client constraint file contains this client version pin that is in conflict
+# with installing the client from source. We should remove the version pin in
+# the constraints file before applying it for from-source installation.
+
+CONSTRAINTS_FILE="$1"
+shift 1
+
+set -e
+
+# NOTE(tonyb): Place this in the tox enviroment's log dir so it will get
+# published to logs.openstack.org for easy debugging.
+localfile="$VIRTUAL_ENV/log/upper-constraints.txt"
+
+if [[ "$CONSTRAINTS_FILE" != http* ]]; then
+    CONSTRAINTS_FILE="file://$CONSTRAINTS_FILE"
+fi
+# NOTE(tonyb): need to add curl to bindep.txt if the project supports bindep
+curl "$CONSTRAINTS_FILE" --insecure --progress-bar --output "$localfile"
+
+pip install -c"$localfile" openstack-requirements
+
+# This is the main purpose of the script: Allow local installation of
+# the current repo. It is listed in constraints file and thus any
+# install will be constrained and we need to unconstrain it.
+edit-constraints "$localfile" -- "$CLIENT_NAME"
+
+pip install -c"$localfile" -U "$@"
+exit $?
diff --git a/tox.ini b/tox.ini
index cdbccbb..9c21dac 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,14 +1,17 @@
 [tox]
-minversion = 1.6
+minversion = 2.0
 envlist = py34,py27,flake8
 skipsdist = True
 
 [testenv]
 usedevelop = True
-install_command = pip install {opts} {packages}
+install_command = {toxinidir}/tools/tox_install.sh {env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt} {opts} {packages}
 deps = -r{toxinidir}/requirements.txt
        -r{toxinidir}/test-requirements.txt
 setenv =
+    VIRTUAL_ENV={envdir}
+    BRANCH_NAME=master
+    CLIENT_NAME=designate-tempest-plugin
   PYTHONDONTWRITEBYTECODE=1
 whitelist_externals = sh
                       find
@@ -73,3 +76,8 @@
 
 ignore = H302,H306,H402,H404,H405,H904,E126,E128
 exclude = .venv,.git,.tox,dist,doc,*openstack/common*,*openstack/deprecated*,*lib/python*,*egg,build,tools,.ropeproject
+
+
+[hacking]
+local-check-factory = designate_tempest_plugin.hacking.checks.factory
+