소스 검색

chore(api): refactored and cleaned up Django tests

- Django tests use common base class
- Updated code style
- Removed duplicate code
- Makes assertions stronger
- Checks all outgoing internet connections
Nils Wisiol 6 년 전
부모
커밋
06b53f1bd4

+ 674 - 0
api/desecapi/tests/base.py

@@ -0,0 +1,674 @@
+import base64
+import random
+import re
+import string
+
+from django.utils import timezone
+from httpretty import httpretty, core as hr_core
+from rest_framework.reverse import reverse
+from rest_framework.test import APITestCase, APIClient
+from rest_framework.utils import json
+
+from api import settings
+from desecapi.models import User, Domain, Token, RRset, RR
+
+
+class DesecAPIClient(APIClient):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.reverse = DesecTestCase.reverse
+
+    def post_rr_set(self, domain_name, **kwargs):
+        kwargs.setdefault('subname', '')
+        kwargs.setdefault('ttl', 60)
+        return self.post(
+            self.reverse('v1:rrsets', name=domain_name),
+            kwargs,
+        )
+
+    def get_rr_sets(self, domain_name, **kwargs):
+        # FIXME add 'v1:rrset@', there seems to be a bug with that
+        return self.get(
+            self.reverse('v1:rrsets', name=domain_name),
+            kwargs
+        )
+
+    def get_rr_set(self, domain_name, subname, type_):
+        # FIXME add 'v1:rrset@', there seems to be a bug with that
+        return self.get(
+            self.reverse('v1:rrset', name=domain_name, subname=subname, type=type_)
+        )
+
+    def put_rr_set(self, domain_name, subname, type_, **kwargs):
+        # FIXME add 'v1:rrset@', there seems to be a bug with that
+        return self.put(
+            self.reverse('v1:rrset', name=domain_name, subname=subname, type=type_),
+            kwargs
+        )
+
+    def patch_rr_set(self, domain_name, subname, type_, **kwargs):
+        # FIXME add 'v1:rrset@', there seems to be a bug with that
+        return self.patch(
+            self.reverse('v1:rrset', name=domain_name, subname=subname, type=type_),
+            kwargs
+        )
+
+    def delete_rr_set(self, domain_name, subname, type_):
+        # FIXME add 'v1:rrset@', there seems to be a bug with that
+        return self.delete(
+            self.reverse('v1:rrset', name=domain_name, subname=subname, type=type_)
+        )
+
+    # TODO add and use {post,get,delete,...}_domain
+
+
+class AssertRequestsContextManager:
+    """
+    Checks that in its context, certain expected requests are made.
+    """
+
+    @classmethod
+    def _flatten_nested_lists(cls, l):
+        for i in l:
+            if isinstance(i, list) or isinstance(i, tuple):
+                yield from cls._flatten_nested_lists(i)
+            else:
+                yield i
+
+    def __init__(self, test_case, expected_requests, single_expectation_single_request=True, expect_order=True):
+        """
+        Initialize a context that checks for made HTTP requests.
+
+        Args:
+            test_case: Test case in which this context lives. Used to fail test if observed requests do not meet
+            expectations.
+            expected_requests: (Possibly nested) list of requests, represented by kwarg-dictionaries for
+            `httpretty.register_uri`.
+            single_expectation_single_request: If True (default), each expected request needs to be matched by exactly
+            one observed request.
+            expect_order: If True (default), requests made are expected in order of expectations given.
+        """
+        self.test_case = test_case
+        self.expected_requests = list(self._flatten_nested_lists(expected_requests))
+        self.single_expectation_single_request = single_expectation_single_request
+        self.expect_order = expect_order
+
+    def __enter__(self):
+        hr_core.POTENTIAL_HTTP_PORTS.add(8081)  # FIXME should depend on self.expected_requests
+        self.expected_requests = self.expected_requests
+        for request in self.expected_requests:
+            httpretty.register_uri(**request)
+
+    @staticmethod
+    def _find_matching_request(pattern, requests):
+        for request in requests:
+            if pattern['method'] == request[0] and pattern['uri'].match(request[1]):
+                return request
+        return None
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        # organize seen requests in a primitive data structure
+        seen_requests = [
+            (r.command, 'http://%s%s' % (r.headers['Host'], r.path)) for r in httpretty.latest_requests
+        ]
+        httpretty.reset()
+        unmatched_requests = seen_requests[:]
+
+        # go through expected requests one by one
+        requests_to_check = list(self.expected_requests)[:]
+        while requests_to_check:
+            request = requests_to_check.pop(0)
+
+            # match request
+            match = None
+            if self.expect_order:
+                if not self.single_expectation_single_request:
+                    raise ValueError(
+                        'Checking of multiple (possibly zero) requests per expectation and checking of request '
+                        'order simultaneously is not implemented, sorry.')
+                if unmatched_requests:
+                    match = self._find_matching_request(request, [unmatched_requests[0]])
+            else:
+                match = self._find_matching_request(
+                    request, unmatched_requests if self.single_expectation_single_request else seen_requests)
+
+            # check match
+            if not match and self.single_expectation_single_request:
+                self.test_case.fail(('Expected to see a %s request on\n\n%s,\n\nbut only saw these %i '
+                                     'requests:\n\n%s\n\nAll expected requests:\n\n%s\n\n'
+                                     'Hint: check for possible duplicates in your expectation.\n' +
+                                     ('Hint: Is the expectation order correct?' if self.expect_order else '')) % (
+                                        request['method'], request['uri'], len(seen_requests),
+                                        '\n'.join(map(str, seen_requests)),
+                                        '\n'.join(map(str, [(r['method'], r['uri']) for r in self.expected_requests])),
+                                     ))
+            if match:
+                unmatched_requests.remove(match)
+
+        # see if any requests were unexpected
+        if unmatched_requests and self.single_expectation_single_request:
+            self.test_case.fail('While waiting for %i request(s), we saw %i unexpected request(s). The unexpected '
+                                'request(s) was/were:\n\n%s\n\nAll recorded requests:\n\n%s\n\nAll expected requests:'
+                                '\n\n%s' % (
+                                    len(self.expected_requests),
+                                    len(unmatched_requests),
+                                    '\n'.join(map(str, unmatched_requests)),
+                                    '\n'.join(map(str, seen_requests)),
+                                    '\n'.join(map(str, [(r['method'], r['uri']) for r in self.expected_requests])),
+                                ))
+
+
+class MockPDNSTestCase(APITestCase):
+    """
+    This test case provides a "mocked Internet" environment with a mock pdns API interface. All internet connections,
+    HTTP or otherwise, that this test case is unaware of will result in an exception.
+
+    By default, requests are intercepted but unexpected and result in a failed test. To set pdns API request
+    expectations, use the `with MockPDNSTestCase.assertPdns*` functions.
+
+    Subclasses may not touch httpretty.enable() or httpretty.disable(). For 'local' usage, httpretty.register_uri()
+    and httpretty.reset() may be used.
+    """
+
+    PDNS_ZONES = r'/zones'
+    PDNS_ZONE_CRYPTO_KEYS = r'/zones/(?P<id>[^/]+)/cryptokeys'
+    PDNS_ZONE = r'/zones/(?P<id>[^/]+)'
+    PDNS_ZONE_NOTIFY = r'/zones/(?P<id>[^/]+)/notify'
+
+    @classmethod
+    def get_full_pdns_url(cls, path_regex, ns='LORD', **kwargs):
+        api = getattr(settings, 'NS%s_PDNS_API' % ns)
+        return re.compile('^' + api + cls.fill_regex_groups(path_regex, **kwargs) + '$')
+
+    @classmethod
+    def fill_regex_groups(cls, template, **kwargs):
+        s = template
+        for name, value in kwargs.items():
+            if value is None:
+                continue
+            pattern = r'\(\?P\<%s\>[^\)]+\)' % name
+            if not re.search(pattern, s):
+                raise ValueError('Tried to fill field %s in template %s, but it does not exist.' % (name, template))
+            s = re.sub(
+                pattern=pattern,
+                repl=value,
+                string=s,
+            )
+
+        return s
+
+    @classmethod
+    def _pdns_zone_id_heuristic(cls, name):
+        """
+        Returns an educated guess of the pdns zone id for a given zone name.
+        """
+        if not name:
+            return None
+
+        name = name.translate(str.maketrans({'/': '=2F', '_': '=5F'}))
+
+        return cls._normalize_name(name)
+
+    @classmethod
+    def _normalize_name(cls, arg):
+        if not arg:
+            return None
+
+        if not isinstance(arg, list):
+            return cls._normalize_name([arg])[0]
+        else:
+            return [x if x.endswith('.') else x + '.' for x in arg]
+
+    @classmethod
+    def request_pdns_zone_create(cls):
+        return {
+            'method': 'POST',
+            'uri': cls.get_full_pdns_url(cls.PDNS_ZONES),
+            'status': 201,
+            'body': None,
+        }
+
+    @classmethod
+    def request_pdns_zone_create_422(cls):
+        request = cls.request_pdns_zone_create()
+        request['status'] = 422
+        return request
+
+    @classmethod
+    def request_pdns_zone_create_already_exists(cls, existing_domains=None):
+        existing_domains = cls._normalize_name(existing_domains)
+
+        def request_callback(r, _, response_headers):
+            body = json.loads(r.parsed_body)
+            if not existing_domains or body['name'] in existing_domains:
+                return [422, response_headers, json.dumps({'error': 'Domain \'%s\' already exists' % body['name']})]
+            else:
+                return [200, response_headers, None]
+
+        request = cls.request_pdns_zone_create_422()
+        request['body'] = request_callback
+        request.pop('status')
+        return request
+
+    @classmethod
+    def request_pdns_zone_delete(cls, name=None, ns='LORD'):
+        return {
+            'method': 'DELETE',
+            'uri': cls.get_full_pdns_url(cls.PDNS_ZONE, ns=ns, id=cls._pdns_zone_id_heuristic(name)),
+            'status': 200,
+            'body': None,
+        }
+
+    @classmethod
+    def request_pdns_zone_update(cls, name=None):
+        return {
+            'method': 'PATCH',
+            'uri': cls.get_full_pdns_url(cls.PDNS_ZONE, id=cls._pdns_zone_id_heuristic(name)),
+            'status': 200,
+            'body': None,
+        }
+
+    @classmethod
+    def request_pdns_zone_update_unknown_type(cls, name=None, unknown_types=None):
+        def request_callback(r, _, response_headers):
+            body = json.loads(r.parsed_body)
+            if not unknown_types or body['rrsets'][0]['type'] in unknown_types:
+                return [
+                    422, response_headers,
+                    json.dumps({'error': 'Mocked error. Unknown RR type %s.' % body['rrsets'][0]['type']})
+                ]
+            else:
+                return [200, response_headers, None]
+
+        request = cls.request_pdns_zone_update(name)
+        request['body'] = request_callback
+        request.pop('status')
+        return request
+
+
+    @classmethod
+    def request_pdns_zone_retrieve(cls, name=None):
+        return {
+            'method': 'GET',
+            'uri': cls.get_full_pdns_url(cls.PDNS_ZONE, id=cls._pdns_zone_id_heuristic(name)),
+            'status': 200,
+            'body': json.dumps({
+                'rrsets': [{
+                    'comments': [],
+                    'name': cls._normalize_name(name) if name else 'test.mock.',
+                    'ttl': 60,
+                    'type': 'NS',
+                    'records': [{'content': ns} for ns in settings.DEFAULT_NS],
+                }]
+            }),
+        }
+
+    @classmethod
+    def request_pdns_zone_retrieve_crypto_keys(cls, name=None):
+        return {
+            'method': 'GET',
+            'uri': cls.get_full_pdns_url(cls.PDNS_ZONE_CRYPTO_KEYS, id=cls._pdns_zone_id_heuristic(name)),
+            'status': 200,
+            'body': json.dumps([
+                {
+                    'active': True,
+                    'algorithm': 'ECDSAP256SHA256',
+                    'bits': 256,
+                    'dnskey': '257 3 13 EVBcsqrnOp6RGWtsrr9QW8cUtt/'
+                              'WI5C81RcCZDTGNI9elAiMQlxRdnic+7V+b7jJDE2vgY08qAbxiNh5NdzkzA==',
+                    'ds': [
+                        '62745 13 1 642d70d9bb84903ca4c4ca08a6e4f1e9465aeaa6',
+                        '62745 13 2 5cddaeaa383e2ea7de49bd1212bf520228f0e3b334626517e5f6a68eb85b48f6',
+                        '62745 13 4 b3f2565901ddcb0b78337301cf863d1045774377bca05c7ad69e17a167734b92'
+                        '9f0a49b7edcca913eb6f5dfeac4645b8'
+                    ],
+                    'flags': 257,
+                    'id': 179425943,
+                    'keytype': key_type,
+                    'type': 'Cryptokey',
+                }
+                for key_type in ['csk', 'ksk', 'zsk']
+            ])
+        }
+
+    @classmethod
+    def request_pdns_zone_notify(cls, name=None):
+        return {
+            'method': 'PUT',
+            'uri': cls.get_full_pdns_url(cls.PDNS_ZONE_NOTIFY, id=cls._pdns_zone_id_heuristic(name)),
+            'status': 200,
+            'body': None,
+        }
+
+    def assertPdnsRequests(self, *expected_requests, expect_order=True):
+        """
+        Assert the given requests are made. To build requests, use the `MockPDNSTestCase.request_*` functions.
+        Unmet expectations will fail the test.
+        Args:
+            *expected_requests: List of expected requests.
+            expect_order: If True (default), the order of observed requests is checked.
+        """
+        return AssertRequestsContextManager(
+            test_case=self,
+            expected_requests=expected_requests,
+            expect_order=expect_order,
+        )
+
+    def assertPdnsNoRequestsBut(self, *expected_requests):
+        """
+        Assert no requests other than the given ones are made. Each request can be matched more than once, unmatched
+        expectations WILL NOT fail the test.
+        Args:
+            *expected_requests: List of acceptable requests to be made.
+        """
+        return AssertRequestsContextManager(
+            test_case=self,
+            expected_requests=expected_requests,
+            single_expectation_single_request=False,
+            expect_order=False,
+        )
+
+    def assertPdnsZoneCreation(self):
+        """
+        Asserts that nslord is contact and a zone is created.
+        """
+        return AssertRequestsContextManager(
+            test_case=self,
+            expected_requests=[
+                self.request_pdns_zone_create()
+            ],
+        )
+
+    def assertPdnsZoneDeletion(self, name=None):
+        """
+        Asserts that nslord and nsmaster are contacted to delete a zone.
+        Args:
+            name: If given, the test is restricted to the name of this zone.
+        """
+        return AssertRequestsContextManager(
+            test_case=self,
+            expected_requests=[
+                self.request_pdns_zone_delete(ns='LORD', name=None),
+                self.request_pdns_zone_delete(ns='MASTER', name=None),
+            ],
+        )
+
+    @classmethod
+    def setUpTestData(cls):
+        httpretty.enable(allow_net_connect=False)
+        httpretty.reset()
+        hr_core.POTENTIAL_HTTP_PORTS.add(8081)  # FIXME static dependency on settings variable
+        for request in [
+            cls.request_pdns_zone_create(),
+            cls.request_pdns_zone_notify(),
+            cls.request_pdns_zone_update(),
+            cls.request_pdns_zone_retrieve_crypto_keys(),
+            cls.request_pdns_zone_retrieve()
+        ]:
+            httpretty.register_uri(**request)
+        cls.setUpTestDataWithPdns()
+        httpretty.reset()
+
+    @classmethod
+    def setUpTestDataWithPdns(cls):
+        """
+        Override this method to set up test data. During the run of this method, httpretty is configured to accept
+        all pdns API requests.
+        """
+        pass
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+        httpretty.disable()
+
+    def setUp(self):
+        super().setUp()
+        httpretty.reset()
+
+
+class DesecTestCase(MockPDNSTestCase):
+    """
+    This test case is run in the "standard" deSEC e.V. setting, i.e. with an API that is aware of the public suffix
+    domains AUTO_DELEGATION_DOMAINS.
+
+    The test case aims to be as close to the deployment as possible and may be extended as the deployment evolves.
+
+    The test case provides an admin user and a regular user for testing.
+    """
+    client_class = DesecAPIClient
+
+    AUTO_DELEGATION_DOMAINS = ['dedyn.io']  # TODO replace with project wide settings
+    PUBLIC_SUFFIXES = ['de', 'com', 'io', 'gov.cd', 'edu.ec', 'xxx', 'pinb.gov.pl', 'valer.ostfold.no', 'kota.aichi.jp']
+
+    @classmethod
+    def reverse(cls, view_name, **kwargs):
+        return reverse(view_name, kwargs=kwargs)
+
+    @classmethod
+    def setUpTestDataWithPdns(cls):
+        super().setUpTestDataWithPdns()
+        random.seed(0xde5ec)
+        cls.admin = cls.create_user(is_admin=True)
+        cls.add_domains = [cls.create_domain(name=name) for name in cls.AUTO_DELEGATION_DOMAINS]
+        cls.user = cls.create_user()
+
+    @classmethod
+    def random_string(cls, length=6, chars=string.ascii_letters + string.digits):
+        return ''.join(random.choice(chars) for _ in range(length))
+
+    @classmethod
+    def random_password(cls, length=12):
+        return cls.random_string(
+            length,
+            chars=string.ascii_letters + string.digits + string.punctuation +
+            'some 💩🐬 UTF-8: “红色联合”对“四·二八兵团”总部大楼的攻击已持续了两天"'
+        )
+
+    @classmethod
+    def random_ip(cls, proto=None):
+        proto = proto or random.choice([4, 6])
+        if proto == 4:
+            return '.'.join([str(random.randrange(256)) for _ in range(4)])
+        elif proto == 6:
+            return '2001:' + ':'.join(['%x' % random.randrange(16**4) for _ in range(7)])
+        else:
+            raise ValueError('Unknown IP protocol version %s. Expected int 4 or int 6.' % str(proto))
+
+    @classmethod
+    def random_username(cls, host=None):
+        host = host or cls.random_domain_name(suffix=random.choice(cls.PUBLIC_SUFFIXES))
+        return cls.random_string() + '+test@' + host.lower()
+
+    @classmethod
+    def random_domain_name(cls, suffix=None):
+        if not suffix:
+            suffix = random.choice(cls.PUBLIC_SUFFIXES)
+        return random.choice(string.ascii_lowercase) + cls.random_string() + '--test' + '.' + suffix
+
+    @classmethod
+    def create_token(cls, user):
+        token = Token.objects.create(user=user)
+        token.save()
+        return token
+
+    @classmethod
+    def create_user(cls, **kwargs):
+        kwargs.setdefault('email', cls.random_username())
+        user = User(**kwargs)
+        user.plain_password = cls.random_string(length=12)
+        user.set_password(user.plain_password)
+        user.save()
+        return user
+
+    @classmethod
+    def create_domain(cls, suffix=None, **kwargs):
+        kwargs.setdefault('owner', cls.create_user())
+        kwargs.setdefault('name', cls.random_domain_name(suffix=suffix))
+        domain = Domain(**kwargs)
+        domain.save()
+        return domain
+
+    @classmethod
+    def create_rr_set(cls, domain, records, **kwargs):
+        if isinstance(domain, str):
+            domain = Domain.objects.get_or_create(name=domain)
+            domain.save()
+        rr_set = RRset(domain=domain, **kwargs)
+        rr_set.save()
+        for r in records:
+            RR(content=r, rrset=rr_set).save()
+        return rr_set
+
+    @classmethod
+    def _find_auto_delegation_zone(cls, name):
+        if not name:
+            return None
+        parents = [parent for parent in cls.AUTO_DELEGATION_DOMAINS if name.endswith('.' + parent)]
+        if not parents:
+            raise ValueError('Could not find auto delegation zone for zone %s; searched in %s' % (
+                name,
+                cls.AUTO_DELEGATION_DOMAINS
+            ))
+        return parents[0]
+
+    @classmethod
+    def requests_desec_domain_creation(cls, name=None):
+        return [
+            cls.request_pdns_zone_create(),
+            cls.request_pdns_zone_notify(name=name),
+            cls.request_pdns_zone_retrieve(name=name),
+            cls.request_pdns_zone_retrieve_crypto_keys(name=name),
+        ]
+
+    @classmethod
+    def requests_desec_domain_deletion(cls, name=None):
+        return [
+            cls.request_pdns_zone_delete(name=name, ns='LORD'),
+            cls.request_pdns_zone_delete(name=name, ns='MASTER'),
+        ]
+
+    @classmethod
+    def requests_desec_domain_creation_auto_delegation(cls, name=None):
+        delegate_at = cls._find_auto_delegation_zone(name)
+        return cls.requests_desec_domain_creation(name=name) + [
+            cls.request_pdns_zone_update(name=delegate_at),
+            cls.request_pdns_zone_notify(name=delegate_at),
+            cls.request_pdns_zone_retrieve_crypto_keys(name=name),
+        ]
+
+    @classmethod
+    def requests_desec_domain_deletion_auto_delegation(cls, name=None):
+        delegate_at = cls._find_auto_delegation_zone(name)
+        return [
+            cls.request_pdns_zone_update(name=delegate_at),
+            cls.request_pdns_zone_notify(name=delegate_at),
+            cls.request_pdns_zone_delete(name=name, ns='LORD'),
+            cls.request_pdns_zone_delete(name=name, ns='MASTER'),
+        ]
+
+    @classmethod
+    def requests_desec_rr_sets_update(cls, name=None):
+        return [
+            cls.request_pdns_zone_update(name=name),
+            cls.request_pdns_zone_notify(name=name),
+        ]
+
+
+class DomainOwnerTestCase(DesecTestCase):
+    """
+    This test case creates a domain owner, some domains for her and some domains that are owned by other users.
+    DomainOwnerTestCase.client is authenticated with the owner's token.
+    """
+    DYN = False
+    NUM_OWNED_DOMAINS = 2
+    NUM_OTHER_DOMAINS = 20
+
+    @classmethod
+    def setUpTestDataWithPdns(cls):
+        super().setUpTestDataWithPdns()
+
+        cls.owner = cls.create_user(dyn=cls.DYN)
+
+        cls.my_domains = [
+            cls.create_domain(suffix=random.choice(cls.AUTO_DELEGATION_DOMAINS) if cls.DYN else '', owner=cls.owner)
+            for _ in range(cls.NUM_OWNED_DOMAINS)
+        ]
+        cls.other_domains = [
+            cls.create_domain(suffix=random.choice(cls.AUTO_DELEGATION_DOMAINS) if cls.DYN else '')
+            for _ in range(cls.NUM_OTHER_DOMAINS)
+        ]
+
+        cls.my_domain = cls.my_domains[0]
+        cls.other_domain = cls.other_domains[0]
+
+        cls.create_rr_set(cls.my_domain, ['127.0.0.1', '127.0.1.1'], type='A', ttl=123)
+        cls.create_rr_set(cls.other_domain, ['40.1.1.1', '40.2.2.2'], type='A', ttl=456)
+
+        cls.token = cls.create_token(user=cls.owner)
+
+    def setUp(self):
+        super().setUp()
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key)
+
+
+class LockedDomainOwnerTestCase(DomainOwnerTestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        super().setUpTestData()
+        cls.owner.locked = timezone.now()
+        cls.owner.save()
+
+
+class DynDomainOwnerTestCase(DomainOwnerTestCase):
+    DYN = True
+
+    @staticmethod
+    def _http_header_base64_conversion(content):
+        return base64.b64encode(content.encode()).decode()
+
+    @staticmethod
+    def _set_credentials(client, authorization):
+        client.credentials(HTTP_AUTHORIZATION=authorization)
+
+    @classmethod
+    def _set_credentials_basic_auth(cls, client, user_name, token):
+        if not user_name and not token:
+            cls._set_credentials(client, '')
+        else:
+            cls._set_credentials(client, 'Basic ' + DynDomainOwnerTestCase._http_header_base64_conversion(
+                    user_name + ':' + token
+                )
+            )
+
+    @classmethod
+    def _set_credentials_token_auth(cls, client, token):
+        if token is None:
+            cls._set_credentials(client, '')
+        else:
+            cls._set_credentials(client, 'Token ' + token)
+
+    def _assertDynDNS12Update(self, requests, mock_remote_addr='', **kwargs):
+        with self.assertPdnsRequests(requests):
+            if mock_remote_addr:
+                return self.client.get(self.reverse('v1:dyndns12update'), kwargs, REMOTE_ADDR=mock_remote_addr)
+            else:
+                return self.client.get(self.reverse('v1:dyndns12update'), kwargs)
+
+    def assertDynDNS12Update(self, domain_name=None, mock_remote_addr='', **kwargs):
+        return self._assertDynDNS12Update(
+            [self.request_pdns_zone_update(name=domain_name), self.request_pdns_zone_notify(name=domain_name)],
+            mock_remote_addr,
+            **kwargs
+        )
+
+    def assertDynDNS12NoUpdate(self, mock_remote_addr='', **kwargs):
+        return self._assertDynDNS12Update([], mock_remote_addr, **kwargs)
+
+    def setUp(self):
+        super().setUp()
+        self.client_token_authorized = self.client_class()
+        self._set_credentials_basic_auth(self.client, self.my_domain.name, self.token.key)
+        self._set_credentials_token_auth(self.client_token_authorized, self.token.key)

+ 224 - 386
api/desecapi/tests/testdomains.py

@@ -1,449 +1,287 @@
-from rest_framework.reverse import reverse
-from rest_framework import status
-from rest_framework.test import APITestCase
-from desecapi.tests.utils import utils
-from desecapi.models import Domain
+import random
+import json
+
 from django.core import mail
-import httpretty
 from django.conf import settings
-import json
-from django.utils import timezone
+from rest_framework import status
+
 from desecapi.exceptions import PdnsException
+from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase, LockedDomainOwnerTestCase
+from desecapi.models import Domain
+
+
+class UnauthenticatedDomainTests(DesecTestCase):
+
+    def test_unauthorized_access(self):
+        for url in [
+            self.reverse('v1:domain-list'),
+            self.reverse('v1:domain-detail', name='example.com.')
+        ]:
+            for method in [self.client.put, self.client.delete]:
+                self.assertEqual(method(url).status_code, status.HTTP_401_UNAUTHORIZED)
 
 
-class UnauthenticatedDomainTests(APITestCase):
-    def testExpectUnauthorizedOnGet(self):
-        url = reverse('v1:domain-list')
-        response = self.client.get(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testExpectUnauthorizedOnPost(self):
-        url = reverse('v1:domain-list')
-        response = self.client.post(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testExpectUnauthorizedOnPut(self):
-        url = reverse('v1:domain-detail', args=('example.com',))
-        response = self.client.put(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testExpectUnauthorizedOnDelete(self):
-        url = reverse('v1:domain-detail', args=('example.com',))
-        response = self.client.delete(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-
-class AuthenticatedDomainTests(APITestCase):
-    def setUp(self):
-        if not hasattr(self, 'owner'):
-            self.owner = utils.createUser()
-            self.ownedDomains = [utils.createDomain(self.owner), utils.createDomain(self.owner)]
-            self.otherDomains = [utils.createDomain(), utils.createDomain()]
-            self.token = utils.createToken(user=self.owner)
-            self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-
-    def tearDown(self):
-        httpretty.reset()
-        httpretty.disable()
-
-    def testExpectOnlyOwnedDomains(self):
-        url = reverse('v1:domain-list')
-        response = self.client.get(url, format='json')
+class DomainOwnerTestCase1(DomainOwnerTestCase):
+
+    def test_list_domains(self):
+        with self.assertPdnsNoRequestsBut(self.request_pdns_zone_retrieve_crypto_keys()):
+            response = self.client.get(self.reverse('v1:domain-list'))
         self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 2)
-        self.assertEqual(response.data[0]['name'], self.ownedDomains[0].name)
-        self.assertEqual(response.data[1]['name'], self.ownedDomains[1].name)
+        self.assertEqual(len(response.data), self.NUM_OWNED_DOMAINS)
+        for i in range(self.NUM_OWNED_DOMAINS):
+            self.assertEqual(response.data[i]['name'], self.my_domains[i].name)
 
-    def testCanDeleteOwnedDomain(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
-        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name+ '.')
+    def test_delete_my_domain(self):
+        url = self.reverse('v1:domain-detail', name=self.my_domain.name)
 
-        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
-        response = self.client.delete(url)
+        with self.assertPdnsRequests(self.requests_desec_domain_deletion()):
+            response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(httpretty.last_request().method, 'DELETE')
-        self.assertEqual(httpretty.last_request().headers['Host'], 'nsmaster:8081')
-
-        httpretty.reset()
-        httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
-        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name+ '.')
+        self.assertFalse(Domain.objects.filter(pk=self.my_domain.pk).exists())
 
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-        self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
 
-    def testCantDeleteOtherDomains(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.reset()
-        httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
-        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
-
-        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
+    def test_delete_other_domain(self):
+        url = self.reverse('v1:domain-detail', name=self.other_domain.name)
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-        self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
-        self.assertTrue(Domain.objects.filter(pk=self.otherDomains[1].pk).exists())
-
-    def testCanGetOwnedDomains(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-
-        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
-        response = self.client.get(url)
+        self.assertTrue(Domain.objects.filter(pk=self.other_domain.pk).exists())
+
+    def test_retrieve_my_domain(self):
+        url = self.reverse('v1:domain-detail', name=self.my_domain.name)
+        with self.assertPdnsRequests(
+            self.request_pdns_zone_retrieve_crypto_keys(name=self.my_domain.name)
+        ):
+            response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['name'], self.ownedDomains[1].name)
+        self.assertEqual(response.data['name'], self.my_domain.name)
         self.assertTrue(isinstance(response.data['keys'], list))
 
-    def testCantGetOtherDomains(self):
-        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+    def test_retrieve_other_domains(self):
+        for domain in self.other_domains:
+            response = self.client.get(self.reverse('v1:domain-detail', name=domain.name))
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
-    def testCantChangeDomainName(self):
-        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
-        response = self.client.get(url)
-        newname = utils.generateDomainname()
-        response.data['name'] = newname
+    def test_update_my_domain_name(self):
+        url = self.reverse('v1:domain-detail', name=self.my_domain.name)
+        with self.assertPdnsRequests(self.request_pdns_zone_retrieve_crypto_keys(name=self.my_domain.name)):
+            response = self.client.get(url)
+
+        response.data['name'] = self.random_domain_name()
         response = self.client.put(url, json.dumps(response.data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-        response = self.client.get(url)
+
+        with self.assertPdnsRequests(self.request_pdns_zone_retrieve_crypto_keys(name=self.my_domain.name)):
+            response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['name'], self.ownedDomains[1].name)
+        self.assertEqual(response.data['name'], self.my_domain.name)
 
-    def testCantPutOtherDomains(self):
-        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
+    def test_update_other_domains(self):
+        url = self.reverse('v1:domain-detail', name=self.other_domain.name)
         response = self.client.put(url, json.dumps({}), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
-    def testCanPostDomains(self):
-        url = reverse('v1:domain-list')
-        data = {'name': utils.generateDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        self.assertEqual(len(mail.outbox), 0)
-
-    def testCanPostReverseDomains(self):
-        name = '0.8.0.0.0.1.c.a.2.4.6.0.c.e.e.d.4.4.0.1.a.0.1.0.8.f.4.0.1.0.a.2.ip6.arpa'
-
-        utils.httpretty_for_pdns_domain_creation(name)
-
-        url = reverse('v1:domain-list')
-        data = {'name': name}
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        self.assertEqual(len(mail.outbox), 0)
-
-    def testCantPostDomainAlreadyTakenInAPI(self):
-        url = reverse('v1:domain-list')
-
-        data = {'name': utils.generateDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
-
-        data = {'name': 'www.' + self.ownedDomains[0].name}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        data = {'name': 'www.' + self.otherDomains[0].name}
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
+    def test_create_domains(self):
+        for name in [
+            '0.8.0.0.0.1.c.a.2.4.6.0.c.e.e.d.4.4.0.1.a.0.1.0.8.f.4.0.1.0.a.2.ip6.arpa',
+            'very.long.domain.name.' + self.random_domain_name(),
+            self.random_domain_name()
+        ]:
+            with self.assertPdnsRequests(self.requests_desec_domain_creation(name)):
+                response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            self.assertEqual(len(mail.outbox), 0)
 
-    def testCantPostDomainAlreadyTakenInPdns(self):
-        name = utils.generateDomainname()
+    def test_create_api_known_domain(self):
+        url = self.reverse('v1:domain-list')
 
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones',
-                               body='{"error": "Domain \'' + name + '.\' already exists"}', status=422)
+        for name in [
+            self.random_domain_name(),
+            'www.' + self.my_domain.name,
+        ]:
+            with self.assertPdnsRequests(self.requests_desec_domain_creation(name)):
+                response = self.client.post(url, {'name': name})
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            response = self.client.post(url, {'name': name})
+            self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
-        url = reverse('v1:domain-list')
-        data = {'name': name}
-        response = self.client.post(url, data)
+    def test_create_pdns_known_domain(self):
+        url = self.reverse('v1:domain-list')
+        name = self.random_domain_name()
+        with self.assertPdnsRequests(self.request_pdns_zone_create_already_exists(existing_domains=[name])):
+            response = self.client.post(url, {'name': name})
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
-    def testCantPostDomainsViolatingPolicy(self):
-        url = reverse('v1:domain-list')
-
-        data = {'name': '*.' + utils.generateDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
+    def test_create_domain_policy(self):
+        name = '*.' + self.random_domain_name()
+        response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertTrue("does not match the required pattern." in response.data['name'][0])
 
-    def testCantPostDomainsWhenAccountIsLocked(self):
-        # Lock user
-        self.owner.locked = timezone.now()
-        self.owner.save()
+    def test_create_domain_atomicity(self):
+        name = self.random_domain_name()
+        with self.assertPdnsRequests(self.request_pdns_zone_create_422()):
+            self.client.post(self.reverse('v1:domain-list'), {'name': name})
+        self.assertFalse(Domain.objects.filter(name=name).exists())
 
-        url = reverse('v1:domain-list')
-        data = {'name': utils.generateDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+    def test_create_domain_punycode(self):
+        names = ['公司.cn', 'aéroport.ci']
+        for name in names:
+            self.assertEqual(
+                self.client.post(self.reverse('v1:domain-list'), {'name': name}).status_code,
+                status.HTTP_400_BAD_REQUEST
+            )
+
+        for name in [n.encode('idna').decode('ascii') for n in names]:
+            with self.assertPdnsRequests(self.requests_desec_domain_creation(name=name)):
+                self.assertEqual(
+                    self.client.post(self.reverse('v1:domain-list'), {'name': name}).status_code,
+                    status.HTTP_201_CREATED
+                )
+
+    def test_create_domain_name_validation(self):
+        for name in [
+            'with space.dedyn.io',
+            'another space.de',
+            ' spaceatthebeginning.com',
+            'percentage%sign.com',
+            '%percentagesign.dedyn.io',
+            'slash/desec.io',
+            '/slashatthebeginning.dedyn.io',
+            '\\backslashatthebeginning.dedyn.io',
+            'backslash\\inthemiddle.at',
+            '@atsign.com',
+            'at@sign.com',
+        ]:
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+            self.assertEqual(len(mail.outbox), 0)
 
-    def testCantModifyDomainsWhenAccountIsLocked(self):
-        name = utils.generateDomainname()
-        data = {'name': name}
-        url = reverse('v1:domain-list')
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        self.client.post(url, data)
 
-        # Lock user
-        self.owner.locked = timezone.now()
-        self.owner.save()
+class LockedDomainOwnerTestCase1(LockedDomainOwnerTestCase):
 
-        url = reverse('v1:domain-detail', args=(name,))
-        data = {'name': 'test.de'}
+    def test_create_domains(self):
+        self.assertEqual(
+            self.client.post(self.reverse('v1:domain-list'), {'name': self.random_domain_name()}).status_code,
+            status.HTTP_403_FORBIDDEN
+        )
 
-        # PATCH
-        response = self.client.patch(url, data)
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+    def test_update_domains(self):
+        url = self.reverse('v1:domain-detail', name=self.my_domain.name)
+        data = {'name': self.random_domain_name()}
 
-        # PUT
-        response = self.client.put(url, data)
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        for method in [self.client.patch, self.client.put]:
+            response = method(url, data)
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
-        # DELETE
-        response = self.client.put(url)
+        response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
-    def testCanPostComplicatedDomains(self):
-        url = reverse('v1:domain-list')
-        data = {'name': 'very.long.domain.name.' + utils.generateDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-    def testPostingCausesPdnsAPICalls(self):
-        name = utils.generateDomainname()
-
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
-
-        url = reverse('v1:domain-list')
-        self.client.post(url, {'name': name})
-
-        self.assertEqual(httpretty.httpretty.latest_requests[-4].method, 'POST')
-        self.assertTrue(name in httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertTrue('ns1.desec.io' in httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertEqual(httpretty.httpretty.latest_requests[-3].method, 'PUT')
-        self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'GET')
-        self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + name + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
-
-    def testDomainDetailURL(self):
-        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
-        self.assertTrue("/" + self.ownedDomains[1].name in url)
-
-    def testRollback(self):
-        name = utils.generateDomainname()
-
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones', body="some error", status=500)
-
-        url = reverse('v1:domain-list')
-        data = {'name': name}
-        self.client.post(url, data)
-
-        self.assertFalse(Domain.objects.filter(name=name).exists())
-
-
-class AuthenticatedDynDomainTests(APITestCase):
-    def setUp(self):
-        if not hasattr(self, 'owner'):
-            self.owner = utils.createUser(dyn=True)
-            self.ownedDomains = [utils.createDomain(self.owner, dyn=True), utils.createDomain(self.owner, dyn=True)]
-            self.otherDomains = [utils.createDomain(), utils.createDomain()]
-            self.token = utils.createToken(user=self.owner)
-            self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-
-    def tearDown(self):
-        httpretty.reset()
-        httpretty.disable()
-
-    def testCanDeleteOwnedDynDomain(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
-        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
+    def test_create_rr_sets(self):
+        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
+        response = self.client.post_rr_set(self.my_domain.name, **data)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
-        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
-        response = self.client.delete(url)
+    def test_update_rr_sets(self):
+        type_ = 'A'
+        for subname in ['', '*', 'asdf', 'asdf.adsf.asdf']:
+            data = {'records': ['1.2.3.4'], 'ttl': 60}
+            response = self.client.put_rr_set(self.my_domain.name, subname, type_, **data)
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+            for patch_request in [
+                {'records': ['1.2.3.4'], 'ttl': 60},
+                {'records': [], 'ttl': 60},
+                {'records': []},
+                {'ttl': 60},
+                {},
+            ]:
+                response = self.client.patch_rr_set(self.my_domain.name, subname, type_, **patch_request)
+                self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+            # Try DELETE
+            response = self.client.delete_rr_set(self.my_domain.name, subname, type_)
+            self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+
+class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
+    DYN = True
+
+    def test_delete_my_domain(self):
+        url = self.reverse('v1:domain-detail', name=self.my_domain.name)
+        with self.assertPdnsRequests(
+            self.requests_desec_domain_deletion_auto_delegation(name=self.my_domain.name)
+        ):
+            response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
-        # FIXME In this testing scenario, the parent domain dedyn.io does not
-        # have the proper NS and DS records set up, so we cannot test their
-        # deletion.
-
-        httpretty.reset()
-        httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
-        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name+ '.')
-
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-        self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
-
-    def testCantDeleteOtherDynDomains(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.reset()
-        httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
-        httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
 
-        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
-        response = self.client.delete(url)
+    def test_delete_other_domains(self):
+        url = self.reverse('v1:domain-detail', name=self.other_domain.name)
+        with self.assertPdnsRequests():
+            response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-        self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
-        self.assertTrue(Domain.objects.filter(pk=self.otherDomains[1].pk).exists())
-
-    def testCanPostDynDomains(self):
-        url = reverse('v1:domain-list')
-        data = {'name': utils.generateDynDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        email = str(mail.outbox[0].message())
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        self.assertEqual(len(mail.outbox), 1)
-        self.assertTrue(data['name'] in email)
-        self.assertTrue(self.token in email)
-
-        # FIXME We also need to test that proper NS and DS records are set up
-        # in the parent zone dedyn.io.  Because this relies on the cron hook,
-        # it is currently not covered.
-
-    def testCantPostNonDynDomains(self):
-        url = reverse('v1:domain-list')
-
-        data = {'name': utils.generateDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
-        self.assertEqual(response.data['code'], 'domain-illformed')
-
-        data = {'name': 'very.long.domain.' + utils.generateDynDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
-        self.assertEqual(response.data['code'], 'domain-illformed')
-
-
-    def testLimitDynDomains(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
-
-        outboxlen = len(mail.outbox)
+        self.assertTrue(Domain.objects.filter(pk=self.other_domain.pk).exists())
 
-        url = reverse('v1:domain-list')
-        for i in range(settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2):
-            name = utils.generateDynDomainname()
-
-            httpretty.register_uri(httpretty.GET,
-                                   settings.NSLORD_PDNS_API + '/zones/' + name + '.',
-                                   body='{"rrsets": []}',
-                                   content_type="application/json")
-            httpretty.register_uri(httpretty.GET,
-                                   settings.NSLORD_PDNS_API + '/zones/' + name + './cryptokeys',
-                                   body='[]',
-                                   content_type="application/json")
-            httpretty.register_uri(httpretty.PUT,
-                                   settings.NSLORD_PDNS_API + '/zones/' + name + './notify',
-                                   status=200)
-
-            response = self.client.post(url, {'name': name})
+    def test_create_auto_delegated_domains(self):
+        for i, suffix in enumerate(self.AUTO_DELEGATION_DOMAINS):
+            name = self.random_domain_name(suffix)
+            with self.assertPdnsRequests(self.requests_desec_domain_creation_auto_delegation(name=name)):
+                response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
             self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-            self.assertEqual(len(mail.outbox), outboxlen+i+1)
+            self.assertEqual(len(mail.outbox), i + 1)
+            email = str(mail.outbox[0].message())
+            self.assertTrue(name in email)
+            self.assertTrue(self.token.key in email)
+            self.assertFalse(self.user.plain_password in email)
+
+    def test_create_regular_domains(self):
+        for name in [
+            self.random_domain_name(),
+            'very.long.domain.' + self.random_domain_name()
+        ]:
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
+            self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
+            self.assertEqual(response.data['code'], 'domain-illformed')
+
+    def test_domain_limit(self):
+        url = self.reverse('v1:domain-list')
+        user_quota = settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT - self.NUM_OWNED_DOMAINS
+
+        for i in range(user_quota):
+            name = self.random_domain_name(random.choice(self.AUTO_DELEGATION_DOMAINS))
+            with self.assertPdnsRequests(self.requests_desec_domain_creation_auto_delegation(name)):
+                response = self.client.post(url, {'name': name})
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            self.assertEqual(len(mail.outbox), i + 1)
 
-        data = {'name': utils.generateDynDomainname()}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
+        response = self.client.post(url, {'name': self.random_domain_name(random.choice(self.AUTO_DELEGATION_DOMAINS))})
         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-        self.assertEqual(len(mail.outbox), outboxlen + settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2)
+        self.assertEqual(len(mail.outbox), user_quota)
 
-    def testCantUseInvalidCharactersInDomainNamePDNS(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
 
-        outboxlen = len(mail.outbox)
-        invalidnames = [
-            'with space.dedyn.io',
-            'another space.de',
-            ' spaceatthebeginning.com',
-            'percentage%sign.com',
-            '%percentagesign.dedyn.io',
-            'slash/desec.io',
-            '/slashatthebeginning.dedyn.io',
-            '\\backslashatthebeginning.dedyn.io',
-            'backslash\\inthemiddle.at',
-            '@atsign.com',
-            'at@sign.com',
-        ]
+class LockedAutoDelegationDomainOwnerTests(LockedDomainOwnerTestCase):
+    DYN = True
 
-        url = reverse('v1:domain-list')
-        for domainname in invalidnames:
-            data = {'name': domainname}
-            utils.httpretty_for_pdns_domain_creation(data['name'])
-            response = self.client.post(url, data)
-            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(len(mail.outbox), outboxlen)
-
-    def testDomainCreationUponUnlockingLockedAccount(self):
-        # Lock user
-        self.owner.locked = timezone.now()
-        self.owner.save()
-
-        newdomain = utils.generateDynDomainname()
-        data = {'name': newdomain}
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + newdomain + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-
-        url = reverse('v1:domain-list')
-
-        # Dyn users should be able to create domains under dedyn.io even when locked
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+    def test_unlock_user(self):
+        name = self.random_domain_name(self.AUTO_DELEGATION_DOMAINS[0])
 
-        # See what happens upon unlock if pdns knows this domain already
-        httpretty.register_uri(httpretty.POST,
-                               settings.NSLORD_PDNS_API + '/zones',
-                               body='{"error": "Domain \'' + newdomain + '.\' already exists"}',
-                               status=422)
+        # Users should be able to create domains under auto delegated domains even when locked
+        with self.assertPdnsRequests(self.request_pdns_zone_retrieve_crypto_keys(name=name)):
+            response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        with self.assertRaises(PdnsException) as cm:
+        with self.assertPdnsRequests(self.request_pdns_zone_create_already_exists(existing_domains=[name])),\
+             self.assertRaises(PdnsException) as cm:
             self.owner.unlock()
 
-        self.assertEqual(str(cm.exception),
-                         "Domain '" + newdomain + ".' already exists")
+        self.assertEqual(str(cm.exception), "Domain '" + name + ".' already exists")
 
         # See what happens upon unlock if this domain is new to pdns
-        httpretty.register_uri(httpretty.POST,
-                               settings.NSLORD_PDNS_API + '/zones')
-
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + newdomain + '.',
-                               body='{"rrsets": [{"comments": [], "name": "%s.", "records": [ { "content": "ns1.desec.io.", "disabled": false }, { "content": "ns2.desec.io.", "disabled": false } ], "ttl": 60, "type": "NS"}]}' % newdomain,
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + newdomain + './notify', status=200)
-
-        self.owner.unlock()
-
-        self.assertEqual(httpretty.httpretty.latest_requests[-3].method, 'POST')
-        self.assertTrue((settings.NSLORD_PDNS_API + '/zones').endswith(httpretty.httpretty.latest_requests[-3].path))
+        with self.assertPdnsRequests(
+                self.requests_desec_domain_creation_auto_delegation(name=name)[:-1]  # No crypto keys retrieved
+        ):
+            self.owner.unlock()

+ 7 - 18
api/desecapi/tests/testdonations.py

@@ -4,25 +4,14 @@ from rest_framework.test import APITestCase
 from django.core import mail
 
 
-class UnsuccessfulDonationTests(APITestCase):
-    def testExpectUnauthorizedOnGet(self):
-        url = reverse('v1:donation')
-        response = self.client.get(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
-
-    def testExpectUnauthorizedOnPut(self):
-        url = reverse('v1:donation')
-        response = self.client.put(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
-
-    def testExpectUnauthorizedOnDelete(self):
-        url = reverse('v1:donation')
-        response = self.client.delete(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
+class DonationTests(APITestCase):
 
+    def test_unauthorized_access(self):
+        for method in [self.client.get, self.client.put, self.client.delete]:
+            response = method(reverse('v1:donation'))
+            self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 
-class SuccessfulDonationTests(APITestCase):
-    def testCanPostDonations(self):
+    def test_create_donation(self):
         url = reverse('v1:donation')
         data = \
             {
@@ -34,7 +23,7 @@ class SuccessfulDonationTests(APITestCase):
                 'email': 'email@example.com',
             }
         response = self.client.post(url, data)
-        self.assertTrue(len(mail.outbox) > 0)
+        self.assertTrue(mail.outbox)
         email_internal = str(mail.outbox[0].message())
         direct_debit = str(mail.outbox[0].attachments[0][1])
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)

+ 114 - 212
api/desecapi/tests/testdyndns12update.py

@@ -1,250 +1,152 @@
-from rest_framework.reverse import reverse
 from rest_framework import status
-from rest_framework.test import APITestCase
-from desecapi.tests.utils import utils
-import base64
-import httpretty
-from django.conf import settings
-import json
-
-
-class DynDNS12UpdateTest(APITestCase):
-    owner = None
-    token = None
-    username = None
-    password = None
-
-    def setUp(self):
-        self.owner = utils.createUser()
-        self.token = utils.createToken(user=self.owner)
-        self.domain = utils.generateDynDomainname()
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-
-        url = reverse('v1:domain-list')
-        data = {'name': self.domain}
-        utils.httpretty_for_pdns_domain_creation(data['name'])
-        response = self.client.post(url, data)
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.username = response.data['name']
-        self.password = self.token
-        self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
-
-        httpretty.enable(allow_net_connect=False)
-        httpretty.HTTPretty.allow_net_connect = False
-        self.httpretty_reset_uris()
-
-    def httpretty_reset_uris(self):
-        httpretty.reset()
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + self.domain + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify')
-
-    def tearDown(self):
-        httpretty.reset()
-        httpretty.disable()
 
-    def assertIP(self, ipv4=None, ipv6=None, name=None):
-        old_credentials = self.client._credentials['HTTP_AUTHORIZATION']
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.password)
-        name = name or self.username
+from desecapi.tests.base import DynDomainOwnerTestCase
+
+
+class DynDNS12UpdateTest(DynDomainOwnerTestCase):
+
+    def assertRRSet(self, name, subname, type_, content):
+        response = self.client_token_authorized.get(self.reverse('v1:rrset', name=name, subname=subname, type=type_))
 
-        def verify_response(type_, ip):
-            url = reverse('v1:rrset', args=(name, '', type_,))
-            response = self.client.get(url)
+        if content:
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            self.assertEqual(response.data['records'][0], content)
+            self.assertEqual(response.data['ttl'], 60)
+        else:
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def assertIP(self, ipv4=None, ipv6=None, name=None):
+        name = name or self.my_domain.name
+        self.assertRRSet(name, '', 'A', ipv4)
+        self.assertRRSet(name, '', 'AAAA', ipv6)
 
-            if ip is not None:
-                self.assertEqual(response.data['records'][0], ip)
-                self.assertEqual(response.status_code, status.HTTP_200_OK)
-                self.assertEqual(response.data['ttl'], 60)
-            else:
-                self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+    def test_identification_by_domain_name(self):
+        self._set_credentials_basic_auth(self.client, self.my_domain.name + '.invalid', self.token.key)
+        response = self.assertDynDNS12NoUpdate(mock_remote_addr='10.5.5.6')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
-        verify_response('A', ipv4)
-        verify_response('AAAA', ipv6)
+    def test_identification_by_query_params(self):
+        # /update?username=foobar.dedyn.io&password=secret
+        self._set_credentials_basic_auth(self.client, None, None)
+        response = self.assertDynDNS12Update(username=self.my_domain.name, password=self.token.key)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv4='127.0.0.1')
 
-        self.client.credentials(HTTP_AUTHORIZATION=old_credentials)
+    def test_deviant_ttl(self):
+        """
+        The dynamic update will try to set the TTL to 60. Here, we create
+        a record with a different TTL beforehand and then make sure that
+        updates still work properly.
+        """
+        with self.assertPdnsRequests(
+            self.request_pdns_zone_update(self.my_domain.name),
+            self.request_pdns_zone_notify(self.my_domain.name),
+        ):
+            response = self.client_token_authorized.patch_rr_set(self.my_domain.name, subname='', type_='A', ttl=3600)
+            self.assertEqual(response.status_code, status.HTTP_200_OK, response.data)
+
+        response = self.assertDynDNS12Update(self.my_domain.name)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, 'good')
+        self.assertIP(ipv4='127.0.0.1')
 
-    def testDynDNS1UpdateDDClientSuccess(self):
+    def test_ddclient_dyndns1_v4_success(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url,
-                                   {
-                                       'action': 'edit',
-                                       'started': 1,
-                                       'hostname': 'YES',
-                                       'host_id': self.username,
-                                       'myip': '10.1.2.3'
-                                   })
+        with self.assertPdnsRequests(
+                self.request_pdns_zone_update(self.my_domain.name),
+                self.request_pdns_zone_notify(self.my_domain.name),
+        ):
+            response = self.client.get(
+                self.reverse('v1:dyndns12update'),
+                {
+                    'action': 'edit',
+                    'started': 1,
+                    'hostname': 'YES',
+                    'host_id': self.my_domain.name,
+                    'myip': '10.1.2.3'
+                }
+            )
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='10.1.2.3')
 
-    def testDynDNS1UpdateDDClientIPv6Success(self):
+    def test_ddclient_dyndns1_v6_success(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url,
-                                   {
-                                       'action': 'edit',
-                                       'started': 1,
-                                       'hostname': 'YES',
-                                       'host_id': self.username,
-                                       'myipv6': '::1337'
-                                   })
+        response = self.assertDynDNS12Update(
+            domain_name=self.my_domain.name,
+            action='edit',
+            started=1,
+            hostname='YES',
+            host_id=self.my_domain.name,
+            myipv6='::1337'
+        )
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='127.0.0.1', ipv6='::1337')
 
-    def testDynDNS2UpdateDDClientIPv4Success(self):
-        #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url,
-                                   {
-                                       'system': 'dyndns',
-                                       'hostname': self.username,
-                                       'myip': '10.2.3.4'
-                                   })
+    def test_ddclient_dyndns2_v4_success(self):
+        # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
+        response = self.assertDynDNS12Update(
+            domain_name=self.my_domain.name,
+            system='dyndns',
+            hostname=self.my_domain.name,
+            myip='10.2.3.4',
+        )
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='10.2.3.4')
 
-    def testDynDNS2UpdateDDClientIPv6Success(self):
-        #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url,
-                                   {
-                                       'system': 'dyndns',
-                                       'hostname': self.username,
-                                       'myipv6': '::1338'
-                                   })
+    def test_ddclient_dyndns2_v6_success(self):
+        # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
+        response = self.assertDynDNS12Update(
+            domain_name=self.my_domain.name,
+            system='dyndns',
+            hostname=self.my_domain.name,
+            myipv6='::666',
+        )
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1', ipv6='::1338')
+        self.assertIP(ipv4='127.0.0.1', ipv6='::666')
 
-    def testFritzBox(self):
-        #/
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url)
+    def test_fritz_box(self):
+        # /
+        response = self.assertDynDNS12Update(self.my_domain.name)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='127.0.0.1')
 
-    def testUnsetIP(self):
-        url = reverse('v1:dyndns12update')
-
-        def testVariant(params, **kwargs):
-            response = self.client.get(url, params)
+    def test_unset_ip(self):
+        for (v4, v6) in [
+            ('127.0.0.1', '::1'),
+            ('127.0.0.1', ''),
+            ('', '::1'),
+            ('', ''),
+        ]:
+            response = self.assertDynDNS12Update(self.my_domain.name, ip=v4, ipv6=v6)
             self.assertEqual(response.status_code, status.HTTP_200_OK)
             self.assertEqual(response.data, 'good')
-            self.assertIP(**kwargs)
-
-        testVariant({'ipv6': '::1337'}, ipv4='127.0.0.1', ipv6='::1337')
-        testVariant({'ipv6': '::1337', 'myip': ''}, ipv4=None, ipv6='::1337')
-        testVariant({'ipv6': '', 'ip': '1.2.3.4'}, ipv4='1.2.3.4', ipv6=None)
-        testVariant({'ipv6': '', 'myipv4': ''}, ipv4=None, ipv6=None)
-
-    def testIdentificationByUsernameDomainname(self):
-        # To force identification by the provided username (which is the domain name)
-        # we add a second domain for the current user.
-
-        name = 'second-' + self.domain
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
-
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('v1:domain-list')
-        response = self.client.post(url, {'name': name})
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='10.5.5.5')
+            self.assertIP(ipv4=v4, ipv6=v6)
 
-        self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + '.invalid:' + self.password).encode()).decode())
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
-    def testIdentificationByTokenWithEmptyUser(self):
-        self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((':' + self.password).encode()).decode())
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url, REMOTE_ADDR='10.5.5.6')
+class SingleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
+    NUM_OWNED_DOMAINS = 1
+
+    def test_identification_by_token(self):
+        self._set_credentials_basic_auth(self.client, '', self.token.key)
+        response = self.assertDynDNS12Update(self.my_domain.name, mock_remote_addr='10.5.5.6')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='10.5.5.6')
 
-        # Now make sure we get a conflict when the user has multiple domains. Thus,
-        # we add a second domain for the current user.
-
-        name = 'second-' + self.domain
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
-
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('v1:domain-list')
-        response = self.client.post(url, {'name': name})
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url, REMOTE_ADDR='10.5.5.7')
-        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
-    def testManual(self):
-        #/update?username=foobar.dedyn.io&password=secret
-        self.client.credentials(HTTP_AUTHORIZATION='')
-        url = reverse('v1:dyndns12update')
-        response = self.client.get(url,
-                                   {
-                                       'username': self.username,
-                                       'password': self.token,
-                                   })
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1')
+class MultipleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
+    NUM_OWNED_DOMAINS = 4
 
-    def testDeviantTTL(self):
-        # The dynamic update will try to set the TTL to 60. Here, we create
-        # a record with a different TTL beforehand and then make sure that
-        # updates still work properly.
-        url = reverse('v1:rrsets', args=(self.domain,))
-        data = {'records': ['127.0.0.1'], 'ttl': 3600, 'type': 'A'}
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        response = self.client.post(url, json.dumps(data),
-                                    content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.httpretty_reset_uris()
-
-        url = reverse('v1:dyndns12update')
-        self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
-        response = self.client.get(url)
-        self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'PATCH')
-        self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1')
+    def test_identification_by_token(self):
+        """
+        Test if the conflict of having multiple domains, but not specifying which to update is correctly recognized.
+        """
+        self._set_credentials_basic_auth(self.client, '', self.token.key)
+        response = self.client.get(self.reverse('v1:dyndns12update'), REMOTE_ADDR='10.5.5.7')
+        self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)

+ 41 - 77
api/desecapi/tests/testdynupdateauthentication.py

@@ -1,77 +1,41 @@
-from rest_framework.reverse import reverse
-from rest_framework import status
-from rest_framework.test import APITestCase
-from desecapi.tests.utils import utils
-import httpretty
-import base64
-from django.conf import settings
-
-
-class DynUpdateAuthenticationTests(APITestCase):
-
-    def setCredentials(self, username, password):
-        self.client.credentials(
-            HTTP_AUTHORIZATION='Basic ' + base64.b64encode((username + ':' + password).encode()).decode())
-
-    def setUp(self):
-        if not hasattr(self, 'owner'):
-            self.username = utils.generateRandomString(12)
-            self.password = utils.generateRandomString(12)
-            self.user = utils.createUser(self.username, self.password)
-            self.token = utils.createToken(user=self.user)
-            self.setCredentials(self.username, self.password)
-            self.url = reverse('v1:dyndns12update')
-
-            self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-            self.domain = utils.generateDynDomainname()
-            url = reverse('v1:domain-list')
-            data = {'name': self.domain}
-            utils.httpretty_for_pdns_domain_creation(data['name'])
-            response = self.client.post(url, data)
-            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-            httpretty.enable(allow_net_connect=False)
-            httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
-            httpretty.register_uri(httpretty.GET,
-                                   settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.',
-                                   body='{"rrsets": []}',
-                                   content_type="application/json")
-            httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.domain + '.')
-            httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.domain + './notify')
-
-    def tearDown(self):
-        httpretty.reset()
-        httpretty.disable()
-
-    def testSuccessfulAuthentication(self):
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-
-    def testWrongUsername(self):
-        self.setCredentials('wrong', self.password)
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testWrongPassword(self):
-        self.setCredentials(self.username, 'wrong')
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testDoubleColonInAuthentication(self):
-        self.client.credentials(
-            HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password + ':bullshit').encode()).decode())
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testlNoColonInAuthentication(self):
-        self.client.credentials(
-            HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + '' + self.password).encode()).decode())
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testNoValidEncoding(self):
-        self.client.credentials(HTTP_AUTHORIZATION='Basic bull[%]shit')
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
+from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_404_NOT_FOUND
+
+from desecapi.tests.base import DynDomainOwnerTestCase
+
+
+class DynUpdateAuthenticationTestCase(DynDomainOwnerTestCase):
+    NUM_OWNED_DOMAINS = 1
+
+    def _get_dyndns12(self):
+        with self.assertPdnsNoRequestsBut(self.requests_desec_rr_sets_update()):
+            return self.client.get(self.reverse('v1:dyndns12update'))
+
+    def assertDynDNS12Status(self, status=HTTP_200_OK, authorization=None):
+        if authorization:
+            self._set_credentials(self.client, 'Basic ' + self._http_header_base64_conversion(authorization))
+        request = self._get_dyndns12()
+        self.assertEqual(request.status_code, status, request)
+
+    def assertDynDNS12AuthenticationStatus(self, username, token, status):
+        # Note that this overwrites self.client's credentials, which may be unexpected
+        self._set_credentials_basic_auth(self.client, username, token)
+        self.assertDynDNS12Status(status)
+
+    def test_username_password(self):
+        # FIXME the following test fails
+        # self.assertDyndns12AuthenticationStatus(self.user.get_username(), self.token.key, HTTP_200_OK)
+        self.assertDynDNS12AuthenticationStatus('', self.token.key, HTTP_200_OK)
+        self.assertDynDNS12AuthenticationStatus('wrong', self.token.key, HTTP_404_NOT_FOUND)
+        self.assertDynDNS12AuthenticationStatus('', 'wrong', HTTP_401_UNAUTHORIZED)
+        self.assertDynDNS12AuthenticationStatus(self.user.get_username(), 'wrong', HTTP_401_UNAUTHORIZED)
+
+    def test_malformed_basic_auth(self):
+        for authorization in [
+            'asdf:asdf:sadf',
+            'asdf',
+            'bull[%]shit',
+            '你好',
+            '💩💩💩💩',
+            '💩💩:💩💩',
+        ]:
+            self.assertDynDNS12Status(authorization=authorization, status=HTTP_401_UNAUTHORIZED)

+ 8 - 7
api/desecapi/tests/testprivacychores.py

@@ -1,17 +1,18 @@
+from datetime import timedelta
+
 from django.core.management import call_command
-from django.test import TestCase
 from django.utils import timezone
+
 from desecapi.models import User
-from .utils import utils
+from desecapi.tests.base import DesecTestCase
 from api import settings
-from datetime import timedelta
 
 
-class PrivacyChoresCommandTest(TestCase):
+class PrivacyChoresCommandTest(DesecTestCase):
 
-    def test_delete_registration_ip_for_old_users(self):
-        name1 = utils.generateUsername()
-        name2 = utils.generateUsername()
+    def test_delete_registration_ip(self):
+        name1 = self.random_username()
+        name2 = self.random_username()
 
         User(
             email=name1,

+ 122 - 174
api/desecapi/tests/testregistration.py

@@ -1,208 +1,156 @@
+from datetime import timedelta
+
 from django.test import RequestFactory
+from django.utils import timezone
+from django.core import mail
 from rest_framework.reverse import reverse
-from rest_framework import status
-from rest_framework.test import APITestCase
 from rest_framework.versioning import NamespaceVersioning
 
-from desecapi.tests.utils import utils
+from desecapi.tests.base import DesecTestCase
 from desecapi import models
-from datetime import timedelta
-from django.utils import timezone
-from django.core import mail
 from desecapi.emails import send_account_lock_email
 from api import settings
 
 
-class RegistrationTest(APITestCase):
+class RegistrationTestCase(DesecTestCase):
 
-    def test_registration_successful(self):
+    def assertRegistration(self, REMOTE_ADDR='', status=201, **kwargs):
         url = reverse('v1:register')
-        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.7")
+        post_kwargs = {}
+        if REMOTE_ADDR:
+            post_kwargs['REMOTE_ADDR'] = REMOTE_ADDR
+        response = self.client.post(url, kwargs, **post_kwargs)
+        self.assertEqual(response.status_code, status, kwargs)
+        return response
+
+
+class SingleRegistrationTestCase(RegistrationTestCase):
+
+    def setUp(self):
+        super().setUp()
+        email = self.random_username()
+        self.assertRegistration(
+            email=email,
+            password=self.random_password(),
+            REMOTE_ADDR="1.3.3.7",
+        )
+        self.user = models.User.objects.get(email=email)
 
-    def test_multiple_registration_locked_same_ip_short_time(self):
-        outboxlen = len(mail.outbox)
+    def test_registration_successful(self):
+        self.assertEqual(self.user.registration_remote_ip, "1.3.3.7")
+        self.assertIsNone(self.user.locked)
 
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(),
-                'password': utils.generateRandomString(size=12), 'dyn': True}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.7")
-        self.assertIsNone(user.locked)
+    def test_token_email(self):
+        self.assertEqual(len(mail.outbox), 1)
+        self.assertTrue(self.user.get_or_create_first_token() in mail.outbox[-1].body)
 
-        self.assertEqual(len(mail.outbox), outboxlen)
+    def test_send_captcha_email_manually(self):
+        # TODO see if this can be replaced by a method of self.client
+        r = RequestFactory().request(HTTP_HOST=settings.ALLOWED_HOSTS[0])
+        r.version = 'v1'
+        r.versioning_scheme = NamespaceVersioning()
+        # end TODO
+
+        mail.outbox = []
+        send_account_lock_email(r, self.user)
+        self.assertEqual(len(mail.outbox), 1)
+
+
+class MultipleRegistrationTestCase(RegistrationTestCase):
+
+    def _registrations(self):
+        pass
+
+    def setUp(self):
+        super().setUp()
+        self.users = []
+        for (ip, hours_ago, email_host) in self._registrations():
+            email = self.random_username(email_host)
+            ip = ip or self.random_ip()
+            self.assertRegistration(
+                email=email,
+                password=self.random_password(),
+                dyn=True,
+                REMOTE_ADDR=ip,
+            )
+            user = models.User.objects.get(email=email)
+            self.assertEqual(user.registration_remote_ip, ip)
+            user.created = timezone.now() - timedelta(hours=hours_ago)
+            user.save()
+            self.users.append(user)
 
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(),
-                'password': utils.generateRandomString(size=12), 'dyn': True}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.7")
-        self.assertIsNotNone(user.locked)
 
-        self.assertEqual(len(mail.outbox), outboxlen + 1)
+class MultipleRegistrationSameIPShortTime(MultipleRegistrationTestCase):
 
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(),
-                'password': utils.generateRandomString(size=12), 'dyn': True}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.7")
-        self.assertIsNotNone(user.locked)
-
-        self.assertEqual(len(mail.outbox), outboxlen + 2)
-
-    def test_multiple_registration_not_locked_different_ip(self):
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.8")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.8")
-        self.assertIsNone(user.locked)
+    NUM_REGISTRATIONS = 3
 
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.9")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.9")
-        self.assertIsNone(user.locked)
-
-    def test_multiple_registration_not_locked_same_ip_long_time(self):
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.10")
-        self.assertIsNone(user.locked)
-
-        #fake registration time
-        user.created = timezone.now() - timedelta(hours=settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS+1)
-        user.save()
+    def _registrations(self):
+        return [('1.3.3.7', 0, None) for _ in range(self.NUM_REGISTRATIONS)]
 
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertEqual(user.registration_remote_ip, "1.3.3.10")
-        self.assertIsNone(user.locked)
+    def test_is_locked(self):
+        self.assertIsNone(self.users[0].locked)
+        for i in range(1, self.NUM_REGISTRATIONS):
+            self.assertIsNotNone(self.users[i].locked)
 
-    def test_send_captcha_email_manually(self):
-        outboxlen = len(mail.outbox)
 
-        url = reverse('v1:register')
-        data = {'email': utils.generateUsername(),
-                'password': utils.generateRandomString(size=12), 'dyn': True}
-        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        r = RequestFactory().request(HTTP_HOST=settings.ALLOWED_HOSTS[0])
-        r.version = 'v1'
-        r.versioning_scheme = NamespaceVersioning()
-        send_account_lock_email(r, user)
+class MultipleRegistrationDifferentIPShortTime(MultipleRegistrationTestCase):
 
-        self.assertEqual(len(mail.outbox), outboxlen+1)
+    NUM_REGISTRATIONS = 10
 
-    def test_multiple_registration_locked_same_email_host(self):
-        outboxlen = len(mail.outbox)
+    def _registrations(self):
+        return [('1.3.3.%s' % i, 0, None) for i in range(self.NUM_REGISTRATIONS)]
 
-        url = reverse('v1:register')
-        for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
-            data = {
-                'email': utils.generateRandomString() + '@test-same-email.desec.io',
-                'password': utils.generateRandomString(size=12),
-                'dyn': True,
-            }
-            response = self.client.post(url, data, REMOTE_ADDR=utils.generateRandomIPv4Address())
-            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-            user = models.User.objects.get(email=data['email'])
-            self.assertEqual(user.email, data['email'])
+    def test_is_not_locked(self):
+        for user in self.users:
             self.assertIsNone(user.locked)
 
-        self.assertEqual(len(mail.outbox), outboxlen)
 
-        url = reverse('v1:register')
-        data = {
-            'email': utils.generateRandomString() + '@test-same-email.desec.io',
-            'password': utils.generateRandomString(size=12),
-            'dyn': True,
-        }
-        response = self.client.post(url, data, REMOTE_ADDR=utils.generateRandomIPv4Address())
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertIsNotNone(user.locked)
-
-        self.assertEqual(len(mail.outbox), outboxlen + 1)
-
-    def test_multiple_registration_not_locked_same_email_host_long_time(self):
-        outboxlen = len(mail.outbox)
+class MultipleRegistrationSameIPLongTime(MultipleRegistrationTestCase):
 
-        url = reverse('v1:register')
-        for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
-            data = {
-                'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
-                'password': utils.generateRandomString(size=12),
-                'dyn': True,
-            }
-            response = self.client.post(url, data, REMOTE_ADDR=utils.generateRandomIPv4Address())
-            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-            user = models.User.objects.get(email=data['email'])
-            self.assertEqual(user.email, data['email'])
+    NUM_REGISTRATIONS = 10
+
+    def _registrations(self):
+        return [
+            ('1.3.3.7', settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS, None)
+            for _ in range(self.NUM_REGISTRATIONS)
+        ]
+
+    def test_is_not_locked(self):
+        for user in self.users:
             self.assertIsNone(user.locked)
 
-            #fake registration time
-            user = models.User.objects.get(email=data['email'])
-            user.created = timezone.now() - timedelta(hours=settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS+1)
-            user.save()
 
-        self.assertEqual(len(mail.outbox), outboxlen)
+class MultipleRegistrationSameEmailHostShortTime(MultipleRegistrationTestCase):
 
-        url = reverse('v1:register')
-        data = {
-            'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
-            'password': utils.generateRandomString(size=12),
-            'dyn': True,
-        }
-        response = self.client.post(url, data, REMOTE_ADDR=utils.generateRandomIPv4Address())
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-        user = models.User.objects.get(email=data['email'])
-        self.assertEqual(user.email, data['email'])
-        self.assertIsNone(user.locked)
-
-        self.assertEqual(len(mail.outbox), outboxlen)
+    NUM_REGISTRATIONS = settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT + 3
 
-    def test_token_email(self):
-        outboxlen = len(mail.outbox)
+    def _registrations(self):
+        host = self.random_domain_name()
+        return [
+            (None, 0, host)
+            for _ in range(self.NUM_REGISTRATIONS)
+        ]
 
-        url = reverse('v1:register')
-        data = {
-            'email': utils.generateRandomString() + '@test-same-email.desec.io',
-            'password': utils.generateRandomString(size=12),
-            'dyn': False,
-        }
-        response = self.client.post(url, data, REMOTE_ADDR=utils.generateRandomIPv4Address())
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.assertEqual(len(mail.outbox), outboxlen + 1)
-
-        user = models.User.objects.get(email=data['email'])
-        self.assertTrue(user.get_or_create_first_token() in mail.outbox[-1].body)
+    def test_is_locked(self):
+        self.assertIsNone(self.users[0].locked)
+        for i in range(self.NUM_REGISTRATIONS):
+            if i < settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT:
+                self.assertIsNone(self.users[i].locked)
+            else:
+                self.assertIsNotNone(self.users[i].locked)
+
+
+class MultipleRegistrationsSameEmailHostLongTime(MultipleRegistrationTestCase):
+
+    NUM_REGISTRATIONS = settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT + 3
+
+    def _registrations(self):
+        host = self.random_domain_name()
+        return [
+            (self.random_ip(), settings.ABUSE_BY_EMAIL_HOSTNAME_PERIOD_HRS + 1, host)
+            for _ in range(self.NUM_REGISTRATIONS)
+        ]
+
+    def test_is_not_locked(self):
+        for user in self.users:
+            self.assertIsNone(user.locked)

+ 332 - 632
api/desecapi/tests/testrrsets.py

@@ -1,675 +1,375 @@
-from rest_framework.reverse import reverse
-from rest_framework import status
-from rest_framework.test import APITestCase
-from desecapi.tests.utils import utils
-import httpretty
+import operator
+from functools import reduce
+
 from django.conf import settings
-import json
 from django.core.management import call_command
-from django.utils import timezone
-
-
-class UnauthenticatedDomainTests(APITestCase):
-    def testExpectUnauthorizedOnGet(self):
-        url = reverse('v1:rrsets', args=('example.com',))
-        response = self.client.get(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testExpectUnauthorizedOnPost(self):
-        url = reverse('v1:rrsets', args=('example.com',))
-        response = self.client.post(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testExpectUnauthorizedOnPut(self):
-        url = reverse('v1:rrsets', args=('example.com',))
-        response = self.client.put(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-    def testExpectUnauthorizedOnDelete(self):
-        url = reverse('v1:rrsets', args=('example.com',))
-        response = self.client.delete(url, format='json')
-        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
-
-
-class AuthenticatedRRsetTests(APITestCase):
-    dead_types = ('ALIAS', 'DNAME')
-    restricted_types = ('SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM')
-
-    def setUp(self):
-        if not hasattr(self, 'owner'):
-            self.owner = utils.createUser()
-            self.ownedDomains = [utils.createDomain(self.owner), utils.createDomain(self.owner)]
-            self.token = utils.createToken(user=self.owner)
-            self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-
-            self.otherOwner = utils.createUser()
-            self.otherDomains = [utils.createDomain(self.otherOwner), utils.createDomain()]
-            self.otherToken = utils.createToken(user=self.otherOwner)
-
-            httpretty.reset()
-            httpretty.enable(allow_net_connect=False)
-            for domain in self.ownedDomains + self.otherDomains:
-                httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + domain.name + '.')
-                httpretty.register_uri(httpretty.PUT,
-                                       settings.NSLORD_PDNS_API + '/zones/' + domain.name + './notify')
-
-    def testCanGetOwnRRsets(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 0)  # NS RRset unavailable in mock pdns environment
-
-    def testCantGetForeignRRsets(self):
-        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-    def testCanGetOwnRRsetsEmptySubname(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        response = self.client.get(url + '?subname=')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 0)  # NS RRset unavailable in mock pdns environment
-
-    def testCanGetOwnRRsetsFromSubname(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-
-        data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        data = {'records': ['2.2.3.4'], 'ttl': 120, 'type': 'A', 'subname': 'test'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        data = {'records': ['"test"'], 'ttl': 120, 'type': 'TXT', 'subname': 'test'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 3)  # NS RRset unavailable in mock pdns environment
-
-        response = self.client.get(url + '?subname=test')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 2)
-
-    def testCantGetForeignRRsetsFromSubname(self):
-        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
-        response = self.client.get(url + '?subname=test')
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-    def testCanGetOwnRRsetsFromType(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-
-        data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        data = {'records': ['2.2.3.4'], 'ttl': 120, 'type': 'A', 'subname': 'test'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        data = {'records': ['"test"'], 'ttl': 120, 'type': 'TXT', 'subname': 'test'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 3)  # NS RRset unavailable in mock pdns environment
-
-        response = self.client.get(url + '?type=A')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 2)
-
-    def testCantGetForeignRRsetsFromType(self):
-        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
-        response = self.client.get(url + '?test=A')
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-    def testCanPostOwnRRsets(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 1)  # NS RRset unavailable in mock pdns environment
+from rest_framework import status
 
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-        response = self.client.get(url)
+from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase
+
+
+class UnauthenticatedRRSetTestCase(DesecTestCase):
+    
+    def test_unauthorized_access(self):
+        url = self.reverse('v1:rrsets', name='example.com')
+        for method in [
+            self.client.get, 
+            self.client.post, 
+            self.client.put, 
+            self.client.delete, 
+            self.client.patch
+        ]:
+            response = method(url)
+            self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+
+class AuthenticatedRRSetTestCase(DomainOwnerTestCase):
+    DEAD_TYPES = ['ALIAS', 'DNAME']
+    RESTRICTED_TYPES = ['SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM', 'OPT']
+
+    # see https://doc.powerdns.com/md/types/
+    PDNS_RR_TYPES = ['A', 'AAAA', 'AFSDB', 'ALIAS', 'CAA', 'CERT', 'CDNSKEY', 'CDS', 'CNAME', 'DNSKEY', 'DNAME', 'DS',
+                     'HINFO', 'KEY', 'LOC', 'MX', 'NAPTR', 'NS', 'NSEC', 'NSEC3', 'NSEC3PARAM', 'OPENPGPKEY', 'PTR',
+                     'RP', 'RRSIG', 'SOA', 'SPF', 'SSHFP', 'SRV', 'TKEY', 'TSIG', 'TLSA', 'SMIMEA', 'TXT', 'URI']
+    ALLOWED_TYPES = ['A', 'AAAA', 'AFSDB', 'CAA', 'CERT', 'CDNSKEY', 'CDS', 'CNAME', 'DS', 'HINFO', 'KEY', 'LOC', 'MX',
+                     'NAPTR', 'NS', 'NSEC', 'NSEC3', 'OPENPGPKEY', 'PTR', 'RP', 'SPF', 'SSHFP', 'SRV', 'TKEY', 'TSIG',
+                     'TLSA', 'SMIMEA', 'TXT', 'URI']
+
+    SUBNAMES = ['foo', 'bar.baz', 'q.w.e.r.t', '*', '*.foobar']
+
+    @classmethod
+    def _test_rr_sets(cls, subname=None, type_=None, records=None, ttl=None):
+        """
+        Gives a list of example RR sets for testing.
+        Args:
+            subname: Filter by subname. None to allow any.
+            type_: Filter by type. None to allow any.
+            records: Filter by records. Must match exactly. None to allow any.
+            ttl: Filter by ttl. None to allow any.
+
+        Returns: Returns a list of tuples that represents example RR sets represented as 4-tuples consisting of
+        subname, type_, records, ttl
+        """
+        # TODO add more examples of cls.ALLOWED_TYPES
+        rr_sets = [
+            ('', 'A', ['1.2.3.4'], 120),
+            ('test', 'A', ['2.2.3.4'], 120),
+            ('test', 'TXT', ['"foobar"'], 120),
+        ] + [
+            (subname, 'TXT', ['"hey ho, let\'s go!"'], 134)
+            for subname in cls.SUBNAMES
+        ] + [
+            (subname, type_, ['"10 mx1.example.com."'], 101)
+            for subname in cls.SUBNAMES
+            for type_ in ['MX', 'SPF']
+        ] + [
+            (subname, 'A', ['"1.2.3.4"'], 187)
+            for subname in cls.SUBNAMES
+        ]
+
+        if subname or type_ or records or ttl:
+            rr_sets = [
+                rr_set for rr_set in rr_sets
+                if (
+                    (subname is None or subname == rr_set[0]) and
+                    (type_ is None or type_ == rr_set[1]) and
+                    (records is None or records == rr_set[2]) and
+                    (ttl is None or ttl == rr_set[3])
+                )
+            ]
+        return rr_sets
+
+    @classmethod
+    def setUpTestDataWithPdns(cls):
+        super().setUpTestDataWithPdns()
+        # TODO this test does not cover "dyn" / auto delegation domains
+        cls.my_empty_domain = cls.create_domain(suffix='', owner=cls.owner)
+        cls.my_rr_set_domain = cls.create_domain(suffix='', owner=cls.owner)
+        cls.other_rr_set_domain = cls.create_domain(suffix='')
+        for domain in [cls.my_rr_set_domain, cls.other_rr_set_domain]:
+            for (subname, type_, records, ttl) in cls._test_rr_sets():
+                cls.create_rr_set(domain, subname=subname, type=type_, records=records, ttl=ttl)
+
+    def assertRRSet(self, response_rr, domain=None, subname=None, records=None, type_=None, **kwargs):
+        kwargs['domain'] = domain
+        kwargs['subname'] = subname
+        kwargs['records'] = records
+        kwargs['type'] = type_
+
+        for key, value in kwargs.items():
+            if value is not None:
+                self.assertEqual(
+                    response_rr[key], value,
+                    'RR set did not have the expected %s: Expected "%s" but was "%s" in %s' % (
+                        key, value, response_rr[key], response_rr
+                    )
+                )
+
+    @staticmethod
+    def _filter_rr_sets(rr_sets, **kwargs):
+        return [
+            rr_sets for rr_set in rr_sets
+            if reduce(operator.and_, [rr_set.get(key, None) == value for key, value in kwargs.items()])
+        ]
+
+    def assertRRSetCount(self, rr_sets, count, **kwargs):
+        filtered_rr_sets = self._filter_rr_sets(rr_sets, **kwargs)
+        if len(filtered_rr_sets) != count:
+            self.fail('Expected to find %i RR set(s) with %s, but only found %i in %s.' % (
+                count, kwargs, len(filtered_rr_sets), rr_sets
+            ))
+
+    def assertContainsRRSet(self, rr_sets, **kwargs):
+        filtered_rr_sets = self._filter_rr_sets(rr_sets, **kwargs)
+        if not filtered_rr_sets:
+            self.fail('Expected to find RR set with %s, but only found %s.' % (
+                kwargs, rr_sets
+            ))
+
+    def test_retrieve_my_rr_sets(self):
+        for response in [
+            self.client.get_rr_sets(self.my_domain.name),
+            self.client.get_rr_sets(self.my_domain.name, subname=''),
+        ]:
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            self.assertEqual(len(response.data), 2, response.data)
+            self.assertContainsRRSet(response.data, subname='', records=settings.DEFAULT_NS, type='NS')
+
+    def test_retrieve_other_rr_sets(self):
+        self.assertEqual(self.client.get_rr_sets(self.other_domain.name).status_code, status.HTTP_404_NOT_FOUND)
+        self.assertEqual(
+            self.client.get_rr_sets(self.other_domain.name, subname='test').status_code, status.HTTP_404_NOT_FOUND)
+        self.assertEqual(
+            self.client.get_rr_sets(self.other_domain.name, type='A').status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_retrieve_my_rr_sets_filter(self):
+        response = self.client.get_rr_sets(self.my_rr_set_domain.name)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '1.2.3.4')
-
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['desec.io.'], 'ttl': 900, 'type': 'PTR'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-    def testCantPostEmptyRRset(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': [], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-        data = {'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-    def testCantPostDeadTypes(self):
-        for type_ in self.dead_types:
-            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-            data = {'records': ['www.example.com.'], 'ttl': 60, 'type': type_}
-            response = self.client.post(url, json.dumps(data), content_type='application/json')
-            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-    def testCantPostRestrictedTypes(self):
-        for type_ in self.restricted_types:
-            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-            data = {'records': ['ns1.desec.io. peter.desec.io. 2584 10800 3600 604800 60'], 'ttl': 60, 'type': type_}
-            response = self.client.post(url, json.dumps(data), content_type='application/json')
-            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-    def testCantPostForeignRRsets(self):
-        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
+        self.assertEqual(len(response.data), len(self._test_rr_sets()) + 1)  # Don't forget about the NS type RR set
+
+        for subname in self.SUBNAMES:
+            response = self.client.get_rr_sets(self.my_rr_set_domain.name, subname=subname)
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            self.assertRRSetCount(response.data, count=len(self._test_rr_sets(subname=subname)), subname=subname)
+
+        for type_ in self.ALLOWED_TYPES:
+            response = self.client.get_rr_sets(self.my_rr_set_domain.name, type=type_)
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            if type_ != 'NS':  # count does not match for NS, that's okay
+                self.assertRRSetCount(response.data, count=len(self._test_rr_sets(type_=type_)), type=type_)
+
+    def test_create_my_rr_sets(self):
+        for subname in ['', 'create-my-rr-sets', 'foo.create-my-rr-sets', 'bar.baz.foo.create-my-rr-sets']:
+            for data in [
+                {'subname': subname, 'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'},
+                {'subname': subname, 'records': ['desec.io.'], 'ttl': 900, 'type': 'PTR'},
+            ]:
+                with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
+                    response = self.client.post_rr_set(domain_name=self.my_empty_domain.name, **data)
+                    self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.data)
+
+                response = self.client.get_rr_sets(self.my_empty_domain.name)
+                self.assertEqual(response.status_code, status.HTTP_200_OK)
+                self.assertRRSetCount(response.data, count=1, **data)
+
+                response = self.client.get_rr_set(self.my_empty_domain.name, data['subname'], data['type'])
+                self.assertEqual(response.status_code, status.HTTP_200_OK)
+                self.assertRRSet(response.data, **data)
+
+    def test_create_my_rr_sets_type_restriction(self):
+        for subname in ['', 'create-my-rr-sets', 'foo.create-my-rr-sets', 'bar.baz.foo.create-my-rr-sets']:
+            for data in [
+                {'subname': subname, 'ttl': 60, 'type': 'a'},
+                {'subname': subname, 'records': ['10 example.com.'], 'ttl': 60, 'type': 'txt'}
+            ] + [
+                {'subname': subname, 'records': ['10 example.com.'], 'ttl': 60, 'type': type_}
+                for type_ in self.DEAD_TYPES
+            ] + [
+                {'subname': subname, 'records': ['ns1.desec.io. peter.desec.io. 2584 10800 3600 604800 60'],
+                 'ttl': 60, 'type': type_}
+                for type_ in self.RESTRICTED_TYPES
+            ]:
+                response = self.client.post_rr_set(self.my_domain.name, **data)
+                self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.data)
+
+                response = self.client.get_rr_sets(self.my_domain.name)
+                self.assertEqual(response.status_code, status.HTTP_200_OK)
+                self.assertRRSetCount(response.data, count=0, **data)
+
+    def test_create_my_rr_sets_without_records(self):
+        for subname in ['', 'create-my-rr-sets', 'foo.create-my-rr-sets', 'bar.baz.foo.create-my-rr-sets']:
+            for data in [
+                {'subname': subname, 'records': [], 'ttl': 60, 'type': 'A'},
+                {'subname': subname, 'ttl': 60, 'type': 'A'},
+            ]:
+                response = self.client.post_rr_set(self.my_empty_domain.name, **data)
+                self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.data)
+
+                response = self.client.get_rr_sets(self.my_empty_domain.name)
+                self.assertEqual(response.status_code, status.HTTP_200_OK)
+                self.assertRRSetCount(response.data, count=0, **data)
+
+    def test_create_other_rr_sets(self):
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        response = self.client.post_rr_set(self.other_domain.name, **data)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
-    def testCantPostTwiceRRsets(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
+    def test_create_my_rr_sets_twice(self):
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+            response = self.client.post_rr_set(self.my_empty_domain.name, **data)
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['3.2.2.1'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
+        data['records'][0] = ['3.2.2.1']
+        response = self.client.post_rr_set(self.my_empty_domain.name, **data)
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
-    def testCantPostFaultyRRsets(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-
-        # New record without a value is a syntactical error --> 400
-        data = {'records': [], 'ttl': 60, 'type': 'TXT'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-        # Lower-case type is a syntactical error --> 400
-        data = {'records': ['123456'], 'ttl': 60, 'type': 'txt'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-        # Unknown type is a semantical error --> 422
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['123456'], 'ttl': 60, 'type': 'AA'}
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.',
-                               body='', status=422)
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
-
-    def testCanGetOwnRRset(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '1.2.3.4')
-        self.assertEqual(response.data['ttl'], 60)
-
-    def testCanGetOwnRRsetApex(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+    def test_create_my_rr_sets_unknown_type(self):
+        for _type in ['AA', 'ASDF']:
+            with self.assertPdnsRequests(
+                    self.request_pdns_zone_update_unknown_type(name=self.my_domain.name, unknown_types=_type)
+            ):
+                response = self.client.post_rr_set(self.my_domain.name, records=['1234'], ttl=60, type=_type)
+                self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
 
-        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '', 'A',))
-        response = self.client.get(url)
+    def test_retrieve_my_rr_sets_apex(self):
+        response = self.client.get_rr_set(self.my_rr_set_domain.name, subname='', type_='A')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
-        self.assertEqual(response.data['ttl'], 60)
+        self.assertEqual(response.data['ttl'], 120)
 
-    def testCantGetRestrictedTypes(self):
-        for type_ in self.restricted_types:
-            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-            response = self.client.get(url + '?type=%s' % type_)
+    def test_retrieve_my_rr_sets_restricted_types(self):
+        for type_ in self.RESTRICTED_TYPES:
+            response = self.client.get_rr_sets(self.my_domain.name, type=type_)
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
-            url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', type_,))
-            response = self.client.get(url)
+            response = self.client.get_rr_sets(self.my_domain.name, type=type_, subname='')
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
-    def testCantGetForeignRRset(self):
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+    def test_update_my_rr_sets(self):
+        for subname in self.SUBNAMES:
+            with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_rr_set_domain.name)):
+                response = self.client.put_rr_set(self.my_rr_set_domain.name, subname, 'A', records=['2.2.3.4'], ttl=30)
+                self.assertEqual(response.status_code, status.HTTP_200_OK)
 
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-    def testCanGetOwnRRsetWithSubname(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-
-        data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            response = self.client.get_rr_set(self.my_rr_set_domain.name, subname, 'A')
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            self.assertEqual(response.data['records'], ['2.2.3.4'])
+            self.assertEqual(response.data['ttl'], 30)
 
-        data = {'records': ['2.2.3.4'], 'ttl': 120, 'type': 'A', 'subname': 'test'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        data = {'records': ['"test"'], 'ttl': 120, 'type': 'TXT', 'subname': 'test'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), 3, response.data)  # NS RRset unavailable in mock pdns environment
-
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, 'test', 'A',))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '2.2.3.4')
-        self.assertEqual(response.data['ttl'], 120)
-        self.assertEqual(response.data['name'], 'test.' + self.ownedDomains[1].name + '.')
-
-    def testCanGetOwnRRsetWithWildcard(self):
-        for subname in ('*', '*.foobar'):
-            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-
-            data = {'records': ['"barfoo"'], 'ttl': 120, 'type': 'TXT', 'subname': subname}
-            response = self.client.post(url, json.dumps(data), content_type='application/json')
-            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-            response1 = self.client.get(url + '?subname=' + subname)
-            self.assertEqual(response1.status_code, status.HTTP_200_OK)
-            self.assertEqual(response1.data[0]['records'][0], '"barfoo"')
-            self.assertEqual(response1.data[0]['ttl'], 120)
-            self.assertEqual(response1.data[0]['name'], subname + '.' + self.ownedDomains[1].name + '.')
-
-            url = reverse('v1:rrset', args=(self.ownedDomains[1].name, subname, 'TXT',))
-            response2 = self.client.get(url)
-            self.assertEqual(response2.data, response1.data[0])
-
-    def testCanPutOwnRRset(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-
-        data = {'records': ['2.2.3.4'], 'ttl': 30}
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '2.2.3.4')
-        self.assertEqual(response.data['ttl'], 30)
-
-        data = {'records': ['3.2.3.4']}
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-        data = {'ttl': 37}
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-    def testCanPutOwnRRsetApex(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'subname': 'sub', 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        # Put a non-empty subdomain here to also test URLs like rrsets/<subname>@/
-        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, 'sub', 'A',))
-
-        data = {'records': ['2.2.3.4'], 'ttl': 30}
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '2.2.3.4')
-        self.assertEqual(response.data['ttl'], 30)
-
-        data = {'records': ['3.2.3.4']}
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-        data = {'ttl': 37}
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-
-    def testCanPatchOwnRRset(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        # Change records and TTL
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-        data = {'records': ['3.2.3.4'], 'ttl': 32}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '3.2.3.4')
-        self.assertEqual(response.data['ttl'], 32)
-
-        # Change records alone
-        data = {'records': ['5.2.3.4']}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '5.2.3.4')
-        self.assertEqual(response.data['ttl'], 32)
-
-        # Change TTL alone
-        data = {'ttl': 37}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '5.2.3.4')
-        self.assertEqual(response.data['ttl'], 37)
-
-        # Change nothing
-        data = {}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '5.2.3.4')
-        self.assertEqual(response.data['ttl'], 37)
-
-    def testCanPatchOwnRRsetApex(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        # Change records and TTL
-        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '', 'A',))
-        data = {'records': ['3.2.3.4'], 'ttl': 32}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '3.2.3.4')
-        self.assertEqual(response.data['ttl'], 32)
-
-        # Change records alone
-        data = {'records': ['5.2.3.4']}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '5.2.3.4')
-        self.assertEqual(response.data['ttl'], 32)
-
-        # Change TTL alone
-        data = {'ttl': 37}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '5.2.3.4')
-        self.assertEqual(response.data['ttl'], 37)
-
-        # Change nothing
-        data = {}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '5.2.3.4')
-        self.assertEqual(response.data['ttl'], 37)
-
-    def testCantChangeForeignRRset(self):
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
-        data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
-
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-    def testCantChangeForeignRRsetApex(self):
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('v1:rrset@', args=(self.otherDomains[0].name, '', 'A',))
-        data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
-
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+            response = self.client.put_rr_set(self.my_rr_set_domain.name, subname, 'A', records=['2.2.3.5'])
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
-    def testCantChangeEssentialProperties(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A', 'subname': 'test1'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            response = self.client.put_rr_set(self.my_rr_set_domain.name, subname, 'A', ttl=37)
+            self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
+    def test_partially_update_my_rr_sets(self):
+        for subname in self.SUBNAMES:
+            current_rr_set = self.client.get_rr_set(self.my_rr_set_domain.name, subname, 'A').data
+            for data in [
+                {'records': ['2.2.3.4'], 'ttl': 30},
+                {'records': ['3.2.3.4']},
+                {'ttl': 37},
+            ]:
+                with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_rr_set_domain.name)):
+                    response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname, 'A', **data)
+                    self.assertEqual(response.status_code, status.HTTP_200_OK)
+    
+                response = self.client.get_rr_set(self.my_rr_set_domain.name, subname, 'A')
+                self.assertEqual(response.status_code, status.HTTP_200_OK)
+                current_rr_set.update(data)
+                self.assertEqual(response.data['records'], current_rr_set['records'])
+                self.assertEqual(response.data['ttl'], current_rr_set['ttl'])
+
+            data = {}
+            response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname, 'A', **data)
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+    def test_partially_update_other_rr_sets(self):
+        for subname in self.SUBNAMES:
+            response = self.client.patch_rr_set(self.other_rr_set_domain.name, subname='',
+                                                type_='A', records=['3.2.3.4'], ttl=334)
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_update_other_rr_sets(self):
+        for subname in self.SUBNAMES:
+            response = self.client.patch_rr_set(self.other_rr_set_domain.name, subname='', type_='A', ttl=305)
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_update_essential_properties(self):
         # Changing the subname is expected to cause an error
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, 'test1', 'A',))
+        url = self.reverse('v1:rrset', name=self.my_rr_set_domain.name, subname='test', type='A')
         data = {'records': ['3.2.3.4'], 'ttl': 120, 'subname': 'test2'}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        response = self.client.patch(url, data)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
+        response = self.client.put(url, data)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
         # Changing the type is expected to cause an error
         data = {'records': ['3.2.3.4'], 'ttl': 120, 'type': 'TXT'}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
+        response = self.client.patch(url, data)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
-        response = self.client.put(url, json.dumps(data), content_type='application/json')
+        response = self.client.put(url, data)
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
         # Check that nothing changed
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '1.2.3.4')
-        self.assertEqual(response.data['ttl'], 60)
-        self.assertEqual(response.data['name'], 'test1.' + self.ownedDomains[1].name + '.')
-        self.assertEqual(response.data['subname'], 'test1')
+        self.assertEqual(response.data['records'][0], '2.2.3.4')
+        self.assertEqual(response.data['ttl'], 120)
+        self.assertEqual(response.data['name'], 'test.' + self.my_rr_set_domain.name + '.')
+        self.assertEqual(response.data['subname'], 'test')
         self.assertEqual(response.data['type'], 'A')
 
         # This is expected to work, but the fields are ignored
         data = {'records': ['3.2.3.4'], 'name': 'example.com.', 'domain': 'example.com'}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_rr_set_domain.name)):
+            response = self.client.patch(url, data)
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
 
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '3.2.3.4')
-        self.assertEqual(response.data['domain'], self.ownedDomains[1].name)
-        self.assertEqual(response.data['name'], 'test1.' + self.ownedDomains[1].name + '.')
-
-    def testCanDeleteOwnRRset(self):
-        # Try PATCH with empty records
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-        data = {'records': []}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-        # Try DELETE
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-    def testCanDeleteOwnRRsetApex(self):
-        # Try PATCH with empty records
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '', 'A',))
-        data = {'records': []}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-        # Try DELETE
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '', 'A',))
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
-
-    def testCantDeleteForeignRRset(self):
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
-
-        # Try PATCH with empty records
-        data = {'records': []}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-
-        # Try DELETE
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-
-        # Make sure it actually is still there
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('v1:rrset@', args=(self.otherDomains[0].name, '', 'A',))
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, status.HTTP_200_OK)
-        self.assertEqual(response.data['records'][0], '1.2.3.4')
-
-    def testCantCreateRRsetWhileAccountIsLocked(self):
-        self.owner.locked = timezone.now()
-        self.owner.save()
-
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
-    def testCantModifyRRsetWhileAccountIsLocked(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        self.owner.locked = timezone.now()
-        self.owner.save()
-
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-
-        # Try PUT
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
-        # Try PATCH
-        data = {'records': ['4.3.2.1']}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
-        # Try PATCH to delete
-        data = {'records': []}
-        response = self.client.patch(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
-        # Try DELETE
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
-
-    def testPostCausesPdnsAPICall(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
-
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        self.client.post(url, json.dumps(data), content_type='application/json')
-
-        result = json.loads(httpretty.httpretty.latest_requests[-2].parsed_body)
-        self.assertEqual(result['rrsets'][0]['name'], self.ownedDomains[1].name + '.')
-        self.assertEqual(result['rrsets'][0]['records'][0]['content'], '1.2.3.4')
-        self.assertEqual(httpretty.last_request().method, 'PUT')
-
-    def testDeleteCausesPdnsAPICall(self):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
-
-        # Create record, should cause a pdns PATCH request and a notify
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        self.client.post(url, json.dumps(data), content_type='application/json')
-
-        # Delete record, should cause a pdns PATCH request and a notify
-        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
-
-        # Check pdns requests from creation
-        result = json.loads(httpretty.httpretty.latest_requests[-4].parsed_body)
-        self.assertEqual(result['rrsets'][0]['name'], self.ownedDomains[1].name + '.')
-        self.assertEqual(result['rrsets'][0]['records'][0]['content'], '1.2.3.4')
-        self.assertEqual(httpretty.httpretty.latest_requests[-3].method, 'PUT')
-
-        # Check pdns requests from deletion
-        result = json.loads(httpretty.httpretty.latest_requests[-2].parsed_body)
-        self.assertEqual(result['rrsets'][0]['name'], self.ownedDomains[1].name + '.')
-        self.assertEqual(result['rrsets'][0]['records'], [])
-        self.assertEqual(httpretty.httpretty.latest_requests[-1].method, 'PUT')
-
-    def testImportRRsets(self):
-        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post(url, json.dumps(data), content_type='application/json')
-        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-
-        # Not checking anything here; errors will raise an exception
-        httpretty.register_uri(httpretty.GET, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.',
-                               status=200, body='{"rrsets":[{"name":"asdf","type":"A",' +
-                                                '"records":[{"content":"1.1.1.1"},{"content":"2.2.2.2"}],"ttl":20}]}')
-        call_command('sync-from-pdns', self.ownedDomains[1].name)
+        self.assertEqual(response.data['domain'], self.my_rr_set_domain.name)
+        self.assertEqual(response.data['name'], 'test.' + self.my_rr_set_domain.name + '.')
+
+    def test_delete_my_rr_sets_with_patch(self):
+        for subname in self.SUBNAMES:
+            with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_rr_set_domain.name)):
+                response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A', records=[])
+                self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+            response = self.client.get_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A')
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_delete_my_rr_sets_with_delete(self):
+        for subname in self.SUBNAMES:
+            with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_rr_set_domain.name)):
+                response = self.client.delete_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A')
+                self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+            response = self.client.get_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A')
+            self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_delete_other_rr_sets(self):
+        for subname in self.SUBNAMES:
+            # Try PATCH empty
+            response = self.client.patch_rr_set(self.other_rr_set_domain.name, subname=subname, type_='A', records=[])
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+            # Try DELETE
+            response = self.client.delete_rr_set(self.other_rr_set_domain.name, subname=subname, type_='A')
+            self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+
+            # Make sure it actually is still there
+            self.assertGreater(len(self.other_rr_set_domain.rrset_set.filter(subname=subname, type='A')), 0)
+
+    def test_import_rr_sets(self):
+        with self.assertPdnsRequests(self.request_pdns_zone_retrieve(name=self.my_domain.name)):
+            call_command('sync-from-pdns', self.my_domain.name)
+        for response in [
+            self.client.get_rr_sets(self.my_domain.name),
+            self.client.get_rr_sets(self.my_domain.name, subname=''),
+        ]:
+            self.assertEqual(response.status_code, status.HTTP_200_OK)
+            self.assertEqual(len(response.data), 1, response.data)
+            self.assertContainsRRSet(response.data, subname='', records=settings.DEFAULT_NS, type='NS')

+ 0 - 83
api/desecapi/tests/utils.py

@@ -1,83 +0,0 @@
-import random
-import string
-
-from httpretty import httpretty
-
-from api import settings
-from desecapi.models import Domain, User, Token
-
-
-class utils(object):
-    @classmethod
-    def generateRandomIPv4Address(cls):
-        return ".".join(map(str, (random.randint(0, 255) for _ in range(4))))
-
-    @classmethod
-    def generateRandomString(cls, size=6, chars=string.ascii_lowercase + string.digits):
-        return ''.join(random.choice(chars) for _ in range(size))
-
-    @classmethod
-    def generateUsername(cls):
-        return cls.generateRandomString() + '@' + cls.generateRandomString() + 'desec.io'
-
-    @classmethod
-    def generateDomainname(cls):
-        return random.choice(string.ascii_lowercase) + cls.generateRandomString() + '.de'
-
-    @classmethod
-    def generateDynDomainname(cls):
-        return random.choice(string.ascii_lowercase) + cls.generateRandomString() + '.dedyn.io'
-
-    """
-    Creates a new user and saves it to the database.
-    The user object is returned.
-    """
-
-    @classmethod
-    def createUser(cls, username=None, password=None, dyn=False):
-        if username is None:
-            username = cls.generateUsername()
-        user = User(email=username, dyn=dyn)
-        user.plainPassword = cls.generateRandomString(size=12) if password is None else password
-        user.set_password(user.plainPassword)
-        user.save()
-        return user
-
-    """
-    Creates a new domain and saves it to the database.
-    The domain object is returned.
-    """
-
-    @classmethod
-    def createDomain(cls, owner=None, dyn=False):
-        if owner is None:
-            owner = cls.createUser(username=None, dyn=False)
-        if dyn:
-            name = cls.generateDynDomainname()
-        else:
-            name = cls.generateDomainname()
-        domain = Domain(name=name, owner=owner)
-        cls.httpretty_for_pdns_domain_creation(name)
-        domain.save()
-        return domain
-
-    @classmethod
-    def httpretty_for_pdns_domain_creation(cls, name):
-        httpretty.enable(allow_net_connect=False)
-        httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones')
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + '.',
-                               body='{"rrsets": []}',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.GET,
-                               settings.NSLORD_PDNS_API + '/zones/' + name + './cryptokeys',
-                               body='[]',
-                               content_type="application/json")
-        httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify',
-                               status=200)
-
-    @classmethod
-    def createToken(cls, user):
-        token = Token.objects.create(user=user)
-        token.save();
-        return token.key;