Browse Source

feat(api): contact pdns through change tracker, replaces write_rrsets

Closes #97

Co-Authored-By: Peter Thomassen <peter@desec.io>
Nils Wisiol 6 years ago
parent
commit
f0d288b205

+ 1 - 1
api/desecapi/exceptions.py

@@ -4,7 +4,7 @@ from json import JSONDecodeError
 from rest_framework.exceptions import APIException
 from rest_framework.exceptions import APIException
 
 
 
 
-class PdnsException(APIException):
+class PDNSException(APIException):
 
 
     def __init__(self, response=None, detail=None, status=None):
     def __init__(self, response=None, detail=None, status=None):
         self.status_code = status or response.status_code
         self.status_code = status or response.status_code

+ 20 - 2
api/desecapi/management/commands/sync-from-pdns.py

@@ -1,6 +1,8 @@
 from django.core.management import BaseCommand, CommandError
 from django.core.management import BaseCommand, CommandError
+from django.db import transaction
 
 
-from desecapi.models import Domain
+from desecapi import pdns
+from desecapi.models import Domain, RRset, RR
 
 
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
@@ -24,7 +26,7 @@ class Command(BaseCommand):
         for domain in domains:
         for domain in domains:
             self.stdout.write('%s ...' % domain.name, ending='')
             self.stdout.write('%s ...' % domain.name, ending='')
             try:
             try:
-                domain.sync_from_pdns()
+                self._sync_domain(domain)
                 self.stdout.write(' synced')
                 self.stdout.write(' synced')
             except Exception as e:
             except Exception as e:
                 if str(e).startswith('Could not find domain ') \
                 if str(e).startswith('Could not find domain ') \
@@ -34,3 +36,19 @@ class Command(BaseCommand):
                     self.stdout.write(' failed')
                     self.stdout.write(' failed')
                     msg = 'Error while processing {}: {}'.format(domain.name, e)
                     msg = 'Error while processing {}: {}'.format(domain.name, e)
                     raise CommandError(msg)
                     raise CommandError(msg)
+
+    @staticmethod
+    @transaction.atomic
+    def _sync_domain(domain):
+        domain.rrset_set.all().delete()
+        rrsets = []
+        rrs = []
+        for rrset_data in pdns.get_rrset_datas(domain):
+            if rrset_data['type'] in RRset.RESTRICTED_TYPES:
+                continue
+            records = rrset_data.pop('records')
+            rrset = RRset(**rrset_data)
+            rrsets.append(rrset)
+            rrs.extend([RR(rrset=rrset, content=record) for record in records])
+        RRset.objects.bulk_create(rrsets)
+        RR.objects.bulk_create(rrs)

+ 94 - 327
api/desecapi/models.py

@@ -1,20 +1,23 @@
+from __future__ import annotations
+
 import datetime
 import datetime
 import random
 import random
 import time
 import time
 import uuid
 import uuid
 from base64 import b64encode
 from base64 import b64encode
-from collections import OrderedDict
 from os import urandom
 from os import urandom
 
 
 import rest_framework.authtoken.models
 import rest_framework.authtoken.models
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
 from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
-from django.core.exceptions import SuspiciousOperation, ValidationError
+from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator, RegexValidator
 from django.core.validators import MinValueValidator, RegexValidator
-from django.db import models, transaction
+from django.db import models
+from django.db.models import Manager
 from django.utils import timezone
 from django.utils import timezone
+from rest_framework.exceptions import APIException
 
 
-from desecapi import pdns, mixins
+from desecapi import pdns
 
 
 
 
 def validate_lower(value):
 def validate_lower(value):
@@ -115,22 +118,6 @@ class User(AbstractBaseUser):
         # Simplest possible answer: All admins are staff
         # Simplest possible answer: All admins are staff
         return self.is_admin
         return self.is_admin
 
 
-    def unlock(self):
-        if self.locked is None:
-            return
-
-        # Create domains on pdns that were created after the account was locked.
-        # Those are obtained using created__gt=self.locked.
-        # Using published=None gives the same result at the time of writing this
-        # comment, but it is not semantically the same. If there ever will be
-        # unpublished domains that are older than the lock, they are not created.
-        for domain in self.domains.filter(created__gt=self.locked):
-            domain.create_on_pdns()
-
-        # Unlock
-        self.locked = None
-        self.save()
-
 
 
 class Token(rest_framework.authtoken.models.Token):
 class Token(rest_framework.authtoken.models.Token):
     key = models.CharField("Key", max_length=40, db_index=True, unique=True)
     key = models.CharField("Key", max_length=40, db_index=True, unique=True)
@@ -155,249 +142,51 @@ class Token(rest_framework.authtoken.models.Token):
         unique_together = (('user', 'user_specific_id'),)
         unique_together = (('user', 'user_specific_id'),)
 
 
 
 
-class Domain(models.Model, mixins.SetterMixin):
+class Domain(models.Model):
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
     name = models.CharField(max_length=191,
     name = models.CharField(max_length=191,
                             unique=True,
                             unique=True,
                             validators=[validate_lower,
                             validators=[validate_lower,
-                                        RegexValidator(regex=r'^[a-z0-9_.-]+$',
+                                        RegexValidator(regex=r'^[a-z0-9_.-]*[a-z]$',
                                                        message='Domain name malformed.',
                                                        message='Domain name malformed.',
                                                        code='invalid_domain_name')
                                                        code='invalid_domain_name')
                                         ])
                                         ])
     owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='domains')
     owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='domains')
     published = models.DateTimeField(null=True, blank=True)
     published = models.DateTimeField(null=True, blank=True)
-    _dirtyName = False
-
-    def setter_name(self, val):
-        if val != self.name:
-            self._dirtyName = True
-
-        return val
-
-    def clean(self):
-        if self._dirtyName:
-            raise ValidationError('You must not change the domain name.')
 
 
     @property
     @property
     def keys(self):
     def keys(self):
-        return pdns.get_keys(self) if self.published else None
-
-    @property
-    def pdns_id(self):
-        if '/' in self.name or '?' in self.name:
-            raise SuspiciousOperation('Invalid hostname ' + self.name)
+        return pdns.get_keys(self)
 
 
-        # See also pdns code, apiZoneNameToId() in ws-api.cc
-        name = self.name.translate(str.maketrans({'/': '=2F', '_': '=5F'}))
+    def partition_name(self):
+        subname, _, parent_name = self.name.partition('.')
+        return subname, parent_name or None
 
 
-        if not name.endswith('.'):
-            name += '.'
-
-        return name
-
-    # This method does not use @transaction.atomic as this could lead to
-    # orphaned zones on pdns.
-    def create_on_pdns(self):
-        """
-        Create zone on pdns
-
-        This method should only be called for new domains when they are created,
-        or when the domain was created with a locked account and not yet propagated.
-        """
-
-        # Throws exception if pdns already knows this zone for some reason
-        # which means that it is not ours and we should not mess with it.
-        # We escalate the exception to let the next level deal with the
-        # response.
-        pdns.create_zone(self, settings.DEFAULT_NS)
-
-        # Update published timestamp on domain
-        self.published = timezone.now()
-        self.save()
-
-        # Make our RRsets consistent with pdns (specifically, NS may exist)
-        self.sync_from_pdns()
-
-        # For domains under our registry, propagate NS and DS delegation RRsets
-        subname, parent_domain = self.name.split('.', 1)
-        if parent_domain in settings.LOCAL_PUBLIC_SUFFIXES:
-            try:
-                parent = Domain.objects.get(name=parent_domain)
-            except Domain.DoesNotExist:
-                pass
-            else:
-                rrsets = RRset.plain_to_rrsets([
-                    {'subname': subname, 'type': 'NS', 'ttl': 3600,
-                     'contents': settings.DEFAULT_NS},
-                    {'subname': subname, 'type': 'DS', 'ttl': 60,
-                     'contents': [ds for k in self.keys for ds in k['ds']]}
-                ], domain=parent)
-                parent.write_rrsets(rrsets)
-
-    @transaction.atomic
-    def sync_from_pdns(self):
-        self.rrset_set.all().delete()
-        rrsets = []
-        rrs = []
-        for rrset_data in pdns.get_rrset_datas(self):
-            if rrset_data['type'] in RRset.RESTRICTED_TYPES:
-                continue
-            records = rrset_data.pop('records')
-            rrset = RRset(**rrset_data)
-            rrsets.append(rrset)
-            rrs.extend([RR(rrset=rrset, content=record) for record in records])
-        RRset.objects.bulk_create(rrsets)
-        RR.objects.bulk_create(rrs)
-
-    @transaction.atomic
-    def write_rrsets(self, rrsets):
-        # Base queryset for all RRsets of the current domain
-        rrset_qs = RRset.objects.filter(domain=self)
-
-        # Set to check RRset uniqueness
-        rrsets_seen = set()
-
-        # We want to return all new, changed, and unchanged RRsets (but not
-        # deleted ones). We store them here, indexed by (subname, type).
-        rrsets_to_return = OrderedDict()
-
-        # Record contents to send to pdns, indexed by their RRset
-        rrsets_for_pdns = {}
-
-        # Always-false Q object: https://stackoverflow.com/a/35894246/6867099
-        q_meaty = models.Q(pk__isnull=True)
-        q_empty = models.Q(pk__isnull=True)
-
-        # Determine which RRsets need to be updated or deleted
-        for rrset, rrs in rrsets.items():
-            if rrset.domain != self:
-                raise ValueError('RRset has wrong domain')
-            if (rrset.subname, rrset.type) in rrsets_seen:
-                raise ValueError('RRset repeated with same subname and type')
-            if rrs is not None and not all(rr.rrset is rrset for rr in rrs):
-                raise ValueError('RR has wrong parent RRset')
-
-            rrsets_seen.add((rrset.subname, rrset.type))
-
-            q = models.Q(subname=rrset.subname, type=rrset.type)
-            if rrs or rrs is None:
-                rrsets_to_return[(rrset.subname, rrset.type)] = rrset
-                q_meaty |= q
-            else:
-                # Set TTL so that pdns does not get confused if missing
-                rrset.ttl = 1
-                rrsets_for_pdns[rrset] = []
-                q_empty |= q
-
-        # Construct querysets representing RRsets that do (not) have RR
-        # contents and lock them
-        qs_meaty = rrset_qs.filter(q_meaty).select_for_update()
-        qs_empty = rrset_qs.filter(q_empty).select_for_update()
-
-        # For existing RRsets, execute TTL updates and/or mark for RR update.
-        # First, let's create a to-do dict; we'll need it later for new RRsets.
-        rrsets_with_new_rrs = []
-        rrsets_meaty_todo = dict(rrsets_to_return)
-        for rrset in qs_meaty.all():
-            rrsets_to_return[(rrset.subname, rrset.type)] = rrset
-
-            rrset_temp = rrsets_meaty_todo.pop((rrset.subname, rrset.type))
-            rrs = {rr.content for rr in rrset.records.all()}
-
-            partial = rrsets[rrset_temp] is None
-            if partial:
-                rrs_temp = rrs
-            else:
-                rrs_temp = {rr.content for rr in rrsets[rrset_temp]}
-
-            # Take current TTL if none was given
-            rrset_temp.ttl = rrset_temp.ttl or rrset.ttl
-
-            changed_ttl = (rrset_temp.ttl != rrset.ttl)
-            changed_rrs = not partial and (rrs_temp != rrs)
-
-            if changed_ttl:
-                rrset.ttl = rrset_temp.ttl
-                rrset.save()
-            if changed_rrs:
-                rrsets_with_new_rrs.append(rrset)
-            if changed_ttl or changed_rrs:
-                rrsets_for_pdns[rrset] = [RR(rrset=rrset, content=rr_content)
-                                          for rr_content in rrs_temp]
-
-        # At this point, rrsets_meaty_todo contains new RRsets only, with
-        # a list of RRs or with None associated.
-        for key, rrset in list(rrsets_meaty_todo.items()):
-            if rrsets[rrset] is None:
-                # None means "don't change RRs". In the context of a new RRset,
-                # this really is no-op, and we do not need to return the RRset.
-                rrsets_to_return.pop((rrset.subname, rrset.type))
-            else:
-                # If there are associated RRs, let's save the RRset. This does
-                # not save the RRs yet.
-                rrsets_with_new_rrs.append(rrset)
-                rrset.save()
-
-            # In either case, send a request to pdns so that we can take
-            # advantage of pdns' type validation check (even if no RRs given).
-            rrsets_for_pdns[rrset] = rrsets[rrset]
-
-        # Repeat lock to make sure new RRsets are also locked
-        rrset_qs.filter(q_meaty).select_for_update()
-
-        # Delete empty RRsets
-        qs_empty.delete()
-
-        # Update contents of modified RRsets
-        RR.objects.filter(rrset__in=rrsets_with_new_rrs).delete()
-        RR.objects.bulk_create([rr
-                                for (rrset, rrs) in rrsets_for_pdns.items()
-                                if rrs and rrset in rrsets_with_new_rrs
-                                for rr in rrs])
-
-        # Update published timestamp on domain
-        self.published = timezone.now()
-        self.save()
-
-        # Send RRsets to pdns
-        if rrsets_for_pdns:
-            pdns.set_rrsets(self, rrsets_for_pdns)
-
-        # Return RRsets
-        return list(rrsets_to_return.values())
-
-    @transaction.atomic
-    def delete(self, *args, **kwargs):
-        # Delete delegation if domain is under our registry
-        subname, parent_domain = self.name.split('.', 1)
-        if parent_domain in settings.LOCAL_PUBLIC_SUFFIXES:
-            try:
-                parent = Domain.objects.get(name=parent_domain)
-            except Domain.DoesNotExist:
-                pass
-            else:
-                rrsets = parent.rrset_set.filter(subname=subname,
-                                                 type__in=['NS', 'DS']).all()
-                parent.write_rrsets({rrset: [] for rrset in rrsets})
-
-        # Delete domain
-        super().delete(*args, **kwargs)
-        pdns.delete_zone(self)
-
-    @transaction.atomic
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
-        new = self.pk is None
-        self.clean()
-        self.clean_fields()
+        self.full_clean(validate_unique=False)
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
-        if new and not self.owner.locked:
-            self.create_on_pdns()
+    def update_delegation(self, child_domain: Domain):
+        child_subname, child_domain_name = child_domain.partition_name()
+        if self.name != child_domain_name:
+            raise ValueError('Cannot update delegation of %s as it is not an immediate child domain of %s.' %
+                             (child_domain.name, self.name))
+
+        if child_domain.pk:
+            # Domain real: set delegation
+            child_keys = child_domain.keys
+            if not child_keys:
+                raise APIException('Cannot delegate %s, as it currently has no keys.' % child_domain.name)
+
+            RRset.objects.create(domain=self, subname=child_subname, type='NS', ttl=3600, contents=settings.DEFAULT_NS)
+            RRset.objects.create(domain=self, subname=child_subname, type='DS', ttl=300,
+                                 contents=[ds for k in child_keys for ds in k['ds']])
+        else:
+            # Domain not real: remove delegation
+            for rrset in self.rrset_set.filter(subname=child_subname, type__in=['NS', 'DS']):
+                rrset.delete()
 
 
     def __str__(self):
     def __str__(self):
-        """
-        Return domain name.
-        """
         return self.name
         return self.name
 
 
     class Meta:
     class Meta:
@@ -429,113 +218,86 @@ class Donation(models.Model):
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         self.iban = self.iban[:6] + "xxx"  # do NOT save account details
         self.iban = self.iban[:6] + "xxx"  # do NOT save account details
-        super().save(*args, **kwargs)  # Call the "real" save() method.
+        super().save(*args, **kwargs)
 
 
     class Meta:
     class Meta:
         ordering = ('created',)
         ordering = ('created',)
 
 
 
 
-class RRset(models.Model, mixins.SetterMixin):
+class RRsetManager(Manager):
+    def create(self, contents=None, **kwargs):
+        rrset = super().create(**kwargs)
+        for content in contents or []:
+            RR.objects.create(rrset=rrset, content=content)
+        return rrset
+
+
+class RRset(models.Model):
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
     updated = models.DateTimeField(null=True)
     updated = models.DateTimeField(null=True)
     domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
     domain = models.ForeignKey(Domain, on_delete=models.CASCADE)
-    subname = models.CharField(max_length=178,
-                               blank=True,
-                               validators=[validate_lower,
-                                           RegexValidator(regex=r'^[*]?[a-z0-9_.-]*$',
-                                                          message='Subname malformed.',
-                                                          code='invalid_subname')
-                                           ]
-                               )
-    type = models.CharField(max_length=10,
-                            validators=[validate_upper,
-                                        RegexValidator(regex=r'^[A-Z][A-Z0-9]*$',
-                                                       message='Type malformed.',
-                                                       code='invalid_type')
-                                        ]
-                            )
+    subname = models.CharField(
+        max_length=178,
+        blank=True,
+        validators=[
+            validate_lower,
+            RegexValidator(
+                regex=r'^([*]|(([*][.])?[a-z0-9_.-]*))$',
+                message='Subname can only use (lowercase) a-z, 0-9, ., -, and _, '
+                        'may start with a \'*.\', or just be \'*\'.',
+                code='invalid_subname'
+            )
+        ]
+    )
+    type = models.CharField(
+        max_length=10,
+        validators=[
+            validate_upper,
+            RegexValidator(
+                regex=r'^[A-Z][A-Z0-9]*$',
+                message='Type must be uppercase alphanumeric and start with a letter.',
+                code='invalid_type'
+            )
+        ]
+    )
     ttl = models.PositiveIntegerField(validators=[MinValueValidator(1)])
     ttl = models.PositiveIntegerField(validators=[MinValueValidator(1)])
 
 
-    _dirty = False
+    objects = RRsetManager()
+
     DEAD_TYPES = ('ALIAS', 'DNAME')
     DEAD_TYPES = ('ALIAS', 'DNAME')
     RESTRICTED_TYPES = ('SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM', 'OPT')
     RESTRICTED_TYPES = ('SOA', 'RRSIG', 'DNSKEY', 'NSEC3PARAM', 'OPT')
 
 
     class Meta:
     class Meta:
         unique_together = (("domain", "subname", "type"),)
         unique_together = (("domain", "subname", "type"),)
 
 
-    def __init__(self, *args, **kwargs):
-        self._dirties = set()
-        super().__init__(*args, **kwargs)
-
-    def setter_domain(self, val):
-        if val != self.domain:
-            self._dirties.add('domain')
-
-        return val
-
-    def setter_subname(self, val):
-        # On PUT, RRsetSerializer sends None, denoting the unchanged value
-        if val is None:
-            return self.subname
-
-        if val != self.subname:
-            self._dirties.add('subname')
-
-        return val
-
-    def setter_type(self, val):
-        if val != self.type:
-            self._dirties.add('type')
-
-        return val
-
-    def setter_ttl(self, val):
-        if val != self.ttl:
-            self._dirties.add('ttl')
-
-        return val
+    @staticmethod
+    def construct_name(subname, domain_name):
+        return '.'.join(filter(None, [subname, domain_name])) + '.'
 
 
-    def clean(self):
-        errors = {}
-        for field in (self._dirties & {'domain', 'subname', 'type'}):
-            errors[field] = ValidationError(
-                'You cannot change the `%s` field.' % field)
+    @property
+    def name(self):
+        return self.construct_name(self.subname, self.domain.name)
 
 
-        if errors:
-            raise ValidationError(errors)
+    def save(self, *args, **kwargs):
+        self.updated = timezone.now()
+        self.full_clean(validate_unique=False)
+        super().save(*args, **kwargs)
 
 
-    def get_dirties(self):
-        return self._dirties
+    def __str__(self):
+        return '<RRSet domain=%s type=%s subname=%s>' % (self.domain.name, self.type, self.subname)
 
 
-    @property
-    def name(self):
-        return '.'.join(filter(None, [self.subname, self.domain.name])) + '.'
 
 
-    @transaction.atomic
-    def delete(self, *args, **kwargs):
-        self.domain.write_rrsets({self: []})
-        self._dirties = {}
+class RRManager(Manager):
+    def bulk_create(self, rrs, **kwargs):
+        ret = super().bulk_create(rrs, **kwargs)
 
 
-    def save(self, *args, **kwargs):
-        # If not new, the only thing that can change is the TTL
-        if self.created is None or 'ttl' in self.get_dirties():
-            self.updated = timezone.now()
-            self.full_clean()
-            # Tell Django to not attempt an update, although the pk is not None
-            kwargs['force_insert'] = (self.created is None)
-            super().save(*args, **kwargs)
-            self._dirties = {}
+        # For each rrset, save once to update published timestamp and trigger signal for post-save processing
+        rrsets = {rr.rrset for rr in rrs}
+        for rrset in rrsets:
+            rrset.save()
 
 
-    @staticmethod
-    def plain_to_rrsets(datas, *, domain):
-        rrsets = {}
-        for data in datas:
-            rrset = RRset(domain=domain, subname=data['subname'],
-                          type=data['type'], ttl=data['ttl'])
-            rrsets[rrset] = [RR(rrset=rrset, content=content)
-                             for content in data['contents']]
-        return rrsets
+        return ret
 
 
 
 
 class RR(models.Model):
 class RR(models.Model):
@@ -544,3 +306,8 @@ class RR(models.Model):
     # max_length is determined based on the calculation in
     # max_length is determined based on the calculation in
     # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
     # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
     content = models.CharField(max_length=4092)
     content = models.CharField(max_length=4092)
+
+    objects = RRManager()
+
+    def __str__(self):
+        return '<RR %s>' % self.content

+ 18 - 84
api/desecapi/pdns.py

@@ -1,11 +1,10 @@
 import json
 import json
-import random
-import socket
-
 import requests
 import requests
 
 
+from django.core.exceptions import SuspiciousOperation
+
 from api import settings as api_settings
 from api import settings as api_settings
-from desecapi.exceptions import PdnsException
+from desecapi.exceptions import PDNSException
 
 
 NSLORD = object()
 NSLORD = object()
 NSMASTER = object()
 NSMASTER = object()
@@ -31,40 +30,14 @@ settings = {
 }
 }
 
 
 
 
-def _pdns_delete_zone(domain):
-    path = '/zones/' + domain.pdns_id
-
-    # We first delete the zone from nslord, the main authoritative source of our DNS data.
-    # However, we do not want to wait for the zone to expire on the slave ("nsmaster").
-    # We thus issue a second delete request on nsmaster to delete the zone there immediately.
-    r1 = requests.delete(settings[NSLORD]['base_url'] + path, headers=settings[NSLORD]['headers'])
-    if r1.status_code < 200 or r1.status_code >= 300:
-        # Deletion technically does not fail if the zone didn't exist in the first place
-        if r1.status_code == 422 and 'Could not find domain' in r1.text:
-            pass
-        else:
-            raise PdnsException(r1)
-
-    # Delete from nsmaster as well
-    r2 = requests.delete(settings[NSMASTER]['base_url'] + path, headers=settings[NSMASTER]['headers'])
-    if r2.status_code < 200 or r2.status_code >= 300:
-        # Deletion technically does not fail if the zone didn't exist in the first place
-        if r2.status_code == 422 and 'Could not find domain' in r2.text:
-            pass
-        else:
-            raise PdnsException(r2)
-
-    return r1, r2
-
-
 def _pdns_request(method, *, server, path, body=None, acceptable_range=range(200, 300)):
 def _pdns_request(method, *, server, path, body=None, acceptable_range=range(200, 300)):
     data = json.dumps(body) if body else None
     data = json.dumps(body) if body else None
     if data is not None and len(data) > api_settings.PDNS_MAX_BODY_SIZE:
     if data is not None and len(data) > api_settings.PDNS_MAX_BODY_SIZE:
-        raise PdnsException(detail='Payload too large', status=413)
+        raise PDNSException(detail='Payload too large', status=413)
 
 
     r = requests.request(method, settings[server]['base_url'] + path, data=data, headers=settings[server]['headers'])
     r = requests.request(method, settings[server]['base_url'] + path, data=data, headers=settings[server]['headers'])
     if r.status_code not in acceptable_range:
     if r.status_code not in acceptable_range:
-        raise PdnsException(r)
+        raise PDNSException(r)
 
 
     return r
     return r
 
 
@@ -78,44 +51,33 @@ def _pdns_patch(server, path, body):
 
 
 
 
 def _pdns_get(server, path):
 def _pdns_get(server, path):
-    return _pdns_request('get', server=server, path=path, acceptable_range=range(200, 400))
+    return _pdns_request('get', server=server, path=path, acceptable_range=range(200, 400))  # FIXME range
 
 
 
 
 def _pdns_put(server, path):
 def _pdns_put(server, path):
-    return _pdns_request('put', server=server, path=path, acceptable_range=range(200, 500))
-
+    return _pdns_request('put', server=server, path=path, acceptable_range=range(200, 500))  # FIXME range
 
 
-def create_zone(domain, nameservers):
-    """
-    Commands pdns to create a zone with the given name and nameservers.
-    """
-    name = domain.name
-    if not name.endswith('.'):
-        name += '.'
 
 
-    salt = '%016x' % random.randrange(16**16)
-    payload = {'name': name, 'kind': 'MASTER', 'dnssec': True,
-               'nsec3param': '1 0 127 %s' % salt, 'nameservers': nameservers}
-    _pdns_post(NSLORD, '/zones', payload)
+def _pdns_delete(server, path):
+    return _pdns_request('delete', server=server, path=path)
 
 
-    payload = {'name': name, 'kind': 'SLAVE', 'masters': [socket.gethostbyname('nslord')]}
-    _pdns_post(NSMASTER, '/zones', payload)
 
 
-    axfr_zone(domain)
+def pdns_id(name):
+    # / is allowed by pdns, but we don't want it
+    if '/' in name or '?' in name:
+        raise SuspiciousOperation('Invalid hostname ' + name)
 
 
+    # See also pdns code, apiZoneNameToId() in ws-api.cc
+    name = name.translate(str.maketrans({'/': '=2F', '_': '=5F'}))
 
 
-def delete_zone(domain):
-    """
-    Commands pdns to delete a zone with the given name.
-    """
-    return _pdns_delete_zone(domain)
+    return name.rstrip('.') + '.'
 
 
 
 
 def get_keys(domain):
 def get_keys(domain):
     """
     """
     Retrieves a dict representation of the DNSSEC key information
     Retrieves a dict representation of the DNSSEC key information
     """
     """
-    r = _pdns_get(NSLORD, '/zones/%s/cryptokeys' % domain.pdns_id)
+    r = _pdns_get(NSLORD, '/zones/%s/cryptokeys' % pdns_id(domain.name))
     return [{k: key[k] for k in ('dnskey', 'ds', 'flags', 'keytype')}
     return [{k: key[k] for k in ('dnskey', 'ds', 'flags', 'keytype')}
             for key in r.json()
             for key in r.json()
             if key['active'] and key['keytype'] in ['csk', 'ksk']]
             if key['active'] and key['keytype'] in ['csk', 'ksk']]
@@ -125,7 +87,7 @@ def get_zone(domain):
     """
     """
     Retrieves a dict representation of the zone from pdns
     Retrieves a dict representation of the zone from pdns
     """
     """
-    r = _pdns_get(NSLORD, '/zones/' + domain.pdns_id)
+    r = _pdns_get(NSLORD, '/zones/' + pdns_id(domain.name))
 
 
     return r.json()
     return r.json()
 
 
@@ -140,31 +102,3 @@ def get_rrset_datas(domain):
              'records': [record['content'] for record in rrset['records']],
              'records': [record['content'] for record in rrset['records']],
              'ttl': rrset['ttl']}
              'ttl': rrset['ttl']}
             for rrset in get_zone(domain)['rrsets']]
             for rrset in get_zone(domain)['rrsets']]
-
-
-def set_rrsets(domain, rrsets, axfr=True):
-    data = {
-        'rrsets':
-        [
-            {
-                'name': rrset.name, 'type': rrset.type, 'ttl': rrset.ttl,
-                'changetype': 'REPLACE',
-                'records': [
-                    {'content': record.content, 'disabled': False}
-                    for record in rrset.records.all()
-                ]
-            }
-            for rrset in rrsets
-        ]
-    }
-    _pdns_patch(NSLORD, '/zones/' + domain.pdns_id, data)
-
-    if axfr:
-        axfr_zone(domain)
-
-
-def axfr_zone(domain):
-    """
-    Commands nsmaster to retrieve the zone from nslord.
-    """
-    _pdns_put(NSMASTER, '/zones/%s/axfr-retrieve' % domain.pdns_id)

+ 354 - 0
api/desecapi/pdns_change_tracker.py

@@ -0,0 +1,354 @@
+import random
+import socket
+
+from django.db.models.signals import post_save, post_delete
+from django.db.transaction import atomic
+from django.utils import timezone
+
+from api import settings as api_settings
+from desecapi.models import RRset, RR, Domain
+from desecapi.pdns import _pdns_post, NSLORD, NSMASTER, _pdns_delete, _pdns_patch, _pdns_put, pdns_id
+
+
+class PDNSChangeTracker:
+    """
+    Hooks up to model signals to maintain two sets:
+
+    - `domain_additions`: set of added domains
+    - `domain_deletions`: set of deleted domains
+
+    The two sets are guaranteed to be disjoint.
+
+    Hooks up to model signals to maintain exactly three sets per domain:
+
+    - `rr_set_additions`: set of added RR sets
+    - `rr_set_modifications`: set of modified RR sets
+    - `rr_set_deletions`: set of deleted RR sets
+
+    `additions` and `deletions` are guaranteed to be disjoint:
+    - If an item is in the set of additions while being deleted, it is removed from `rr_set_additions`.
+    - If an item is in the set of deletions while being added, it is removed from `rr_set_deletions`.
+    `modifications` and `deletions` are guaranteed to be disjoint.
+    - If an item is in the set of deletions while being modified, an exception is raised.
+    - If an item is in the set of modifications while being deleted, it is removed from `rr_set_modifications`.
+    """
+
+    class PDNSChange:
+        """
+        A reversible, atomic operation against the powerdns API.
+        """
+
+        def __init__(self, domain_name):
+            self._domain_name = domain_name
+
+        @property
+        def domain_name(self):
+            return self._domain_name
+
+        @property
+        def domain_name_normalized(self):
+            return self._domain_name + '.'
+
+        @property
+        def domain_pdns_id(self):
+            return pdns_id(self._domain_name)
+
+        @property
+        def axfr_required(self):
+            raise NotImplementedError()
+
+        def pdns_do(self):
+            raise NotImplementedError()
+
+        def api_do(self):
+            raise NotImplementedError()
+
+    class CreateDomain(PDNSChange):
+        @property
+        def axfr_required(self):
+            return True
+
+        def pdns_do(self):
+            salt = '%016x' % random.randrange(16 ** 16)
+            _pdns_post(
+                NSLORD, '/zones',
+                {
+                    'name': self.domain_name_normalized,
+                    'kind': 'MASTER',
+                    'dnssec': True,
+                    'nsec3param': '1 0 127 %s' % salt,
+                    'nameservers': api_settings.DEFAULT_NS
+                }
+            )
+
+            _pdns_post(
+                NSMASTER, '/zones',
+                {
+                    'name': self.domain_name_normalized,
+                    'kind': 'SLAVE',
+                    'masters': [socket.gethostbyname('nslord')]
+                }
+            )
+
+        def api_do(self):
+            rr_set = RRset(
+                domain=Domain.objects.get(name=self.domain_name),
+                type='NS', subname='',
+                ttl=3600,  # TODO configure this via env settings
+            )
+            rr_set.save()
+
+            rrs = [RR(rrset=rr_set, content=ns) for ns in api_settings.DEFAULT_NS]
+            RR.objects.bulk_create(rrs)  # One INSERT
+
+    class DeleteDomain(PDNSChange):
+        @property
+        def axfr_required(self):
+            return False
+
+        def pdns_do(self):
+            _pdns_delete(NSLORD, '/zones/' + self.domain_pdns_id)
+            _pdns_delete(NSMASTER, '/zones/' + self.domain_pdns_id)
+
+        def api_do(self):
+            pass
+
+    class CreateUpdateDeleteRRSets(PDNSChange):
+        def __init__(self, domain_name, additions, modifications, deletions):
+            super().__init__(domain_name)
+            self._additions = additions
+            self._modifications = modifications
+            self._deletions = deletions
+
+        @property
+        def axfr_required(self):
+            return True
+
+        def pdns_do(self):
+            data = {
+                'rrsets':
+                    [
+                        {
+                            'name': RRset.construct_name(subname, self._domain_name),
+                            'type': type_,
+                            'ttl': RRset.objects.values_list('ttl', flat=True).get(domain__name=self._domain_name,
+                                                                                   type=type_, subname=subname),
+                            'changetype': 'REPLACE',
+                            'records': [
+                                {'content': rr.content, 'disabled': False}
+                                for rr in RR.objects.filter(
+                                    rrset__domain__name=self._domain_name,
+                                    rrset__type=type_,
+                                    rrset__subname=subname)
+                            ]
+                        }
+                        for type_, subname in (self._additions | self._modifications) - self._deletions
+                    ] + [
+                        {
+                            'name': RRset.construct_name(subname, self._domain_name),
+                            'type': type_,
+                            'changetype': 'DELETE',
+                            'records': []
+                        }
+                        for type_, subname in self._deletions
+                    ]
+            }
+
+            if data['rrsets']:
+                _pdns_patch(NSLORD, '/zones/' + self.domain_pdns_id, data)
+
+        def api_do(self):
+            pass
+
+    def __init__(self):
+        self._domain_additions = set()
+        self._domain_deletions = set()
+        self._rr_set_additions = {}
+        self._rr_set_modifications = {}
+        self._rr_set_deletions = {}
+        self.transaction = None
+
+    def _manage_signals(self, method):
+        if method not in ['connect', 'disconnect']:
+            raise ValueError()
+        getattr(post_save, method)(self._on_rr_post_save, sender=RR)
+        getattr(post_delete, method)(self._on_rr_post_delete, sender=RR)
+        getattr(post_save, method)(self._on_rr_set_post_save, sender=RRset)
+        getattr(post_delete, method)(self._on_rr_set_post_delete, sender=RRset)
+        getattr(post_save, method)(self._on_domain_post_save, sender=Domain)
+        getattr(post_delete, method)(self._on_domain_post_delete, sender=Domain)
+
+    def __enter__(self):
+        self._domain_additions = set()
+        self._domain_deletions = set()
+        self._rr_set_additions = {}
+        self._rr_set_modifications = {}
+        self._rr_set_deletions = {}
+        self._manage_signals('connect')
+        self.transaction = atomic()
+        self.transaction.__enter__()
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self._manage_signals('disconnect')
+
+        if exc_type:
+            # An exception occurred inside our context, exit db transaction and dismiss pdns changes
+            self.transaction.__exit__(exc_type, exc_val, exc_tb)
+            return
+
+        # TODO introduce two phase commit protocol
+        changes = self._compute_changes()
+        axfr_required = set()
+        for change in changes:
+            try:
+                change.pdns_do()
+                change.api_do()
+                if change.axfr_required:
+                    axfr_required.add(change.domain_name)
+            except Exception as e:
+                # TODO gather as much info as possible
+                #  see if pdns and api are possibly in an inconsistent state
+                self.transaction.__exit__(type(e), e, e.__traceback__)
+                raise e
+
+        self.transaction.__exit__(None, None, None)
+
+        for name in axfr_required:
+            _pdns_put(NSMASTER, '/zones/%s/axfr-retrieve' % pdns_id(name))
+        Domain.objects.filter(name__in=axfr_required).update(published=timezone.now())
+
+    def _compute_changes(self):
+        changes = []
+
+        for domain_name in self._domain_deletions:
+            # discard any RR set modifications
+            self._rr_set_additions.pop(domain_name, None)
+            self._rr_set_modifications.pop(domain_name, None)
+            self._rr_set_deletions.pop(domain_name, None)
+
+            changes.append(PDNSChangeTracker.DeleteDomain(domain_name))
+
+        for domain_name in self._rr_set_additions.keys() | self._domain_additions:
+            if domain_name in self._domain_additions:
+                changes.append(PDNSChangeTracker.CreateDomain(domain_name))
+
+            additions = self._rr_set_additions.get(domain_name, set())
+            modifications = self._rr_set_modifications.get(domain_name, set())
+            deletions = self._rr_set_deletions.get(domain_name, set())
+
+            assert not (additions & deletions)
+            assert not (modifications & deletions)
+
+            # Due to disjoint guarantees with `deletions`, we have four types of RR sets:
+            # (1) purely added RR sets
+            # (2) purely modified RR sets
+            # (3) added and modified RR sets
+            # (4) purely deleted RR sets
+
+            # We send RR sets to PDNS if one of the following conditions holds:
+            # (a) RR set was added and has at least one RR
+            # (b) RR set was modified
+            # (c) RR set was deleted
+
+            # Conditions (b) and (c) are already covered in the modifications and deletions list,
+            # we filter the additions list to remove newly-added, but empty RR sets
+            additions -= {
+                (type_, subname) for (type_, subname) in additions
+                if not RR.objects.filter(
+                    rrset__domain__name=domain_name,
+                    rrset__type=type_,
+                    rrset__subname=subname).exists()
+            }
+
+            if additions | modifications | deletions:
+                changes.append(PDNSChangeTracker.CreateUpdateDeleteRRSets(
+                    domain_name, additions, modifications, deletions))
+
+        return changes
+
+    def _rr_set_updated(self, rr_set: RRset, deleted=False, created=False):
+        if self._rr_set_modifications.get(rr_set.domain.name, None) is None:
+            self._rr_set_additions[rr_set.domain.name] = set()
+            self._rr_set_modifications[rr_set.domain.name] = set()
+            self._rr_set_deletions[rr_set.domain.name] = set()
+
+        additions = self._rr_set_additions[rr_set.domain.name]
+        modifications = self._rr_set_modifications[rr_set.domain.name]
+        deletions = self._rr_set_deletions[rr_set.domain.name]
+
+        item = (rr_set.type, rr_set.subname)
+        if created:
+            additions.add(item)
+            assert item not in modifications
+            deletions.discard(item)
+        elif deleted:
+            if item in additions:
+                additions.remove(item)
+                modifications.discard(item)
+                # no change to deletions
+            else:
+                # item not in additions
+                modifications.discard(item)
+                deletions.add(item)
+        elif not created and not deleted:
+            # we don't care if item was created or not
+            modifications.add(item)
+            assert item not in deletions
+        else:
+            raise ValueError('An RR set cannot be created and deleted at the same time.')
+
+    def _domain_updated(self, domain: Domain, created=False, deleted=False):
+        if not created and not deleted:
+            # NOTE that the name must not be changed by API contract with models, hence here no-op for pdns.
+            return
+
+        name = domain.name
+        additions = self._domain_additions
+        deletions = self._domain_deletions
+
+        if created and deleted:
+            raise ValueError('A domain set cannot be created and deleted at the same time.')
+
+        if created:
+            if name in deletions:
+                deletions.remove(name)
+            else:
+                additions.add(name)
+        elif deleted:
+            if name in additions:
+                additions.remove(name)
+            else:
+                deletions.add(name)
+
+    # noinspection PyUnusedLocal
+    def _on_rr_post_save(self, signal, sender, instance: RR, created, update_fields, raw, using, **kwargs):
+        self._rr_set_updated(instance.rrset)
+
+    # noinspection PyUnusedLocal
+    def _on_rr_post_delete(self, signal, sender, instance: RR, using, **kwargs):
+        self._rr_set_updated(instance.rrset)
+
+    # noinspection PyUnusedLocal
+    def _on_rr_set_post_save(self, signal, sender, instance: RRset, created, update_fields, raw, using, **kwargs):
+        self._rr_set_updated(instance, created=created)
+
+    # noinspection PyUnusedLocal
+    def _on_rr_set_post_delete(self, signal, sender, instance: RRset, using, **kwargs):
+        self._rr_set_updated(instance, deleted=True)
+
+    # noinspection PyUnusedLocal
+    def _on_domain_post_save(self, signal, sender, instance: Domain, created, update_fields, raw, using, **kwargs):
+        self._domain_updated(instance, created=created)
+
+    # noinspection PyUnusedLocal
+    def _on_domain_post_delete(self, signal, sender, instance: Domain, using, **kwargs):
+        self._domain_updated(instance, deleted=True)
+
+    def __str__(self):
+        all_rr_sets = self._rr_set_additions.keys() | self._rr_set_modifications.keys() | self._rr_set_deletions.keys()
+        all_domains = self._domain_additions | self._domain_deletions
+        return '<%s: %i added or deleted domains; %i added, modified or deleted RR sets>' % (
+            self.__class__.__name__,
+            len(all_domains),
+            len(all_rr_sets)
+        )

+ 0 - 14
api/desecapi/permissions.py

@@ -30,17 +30,3 @@ class IsUnlocked(permissions.BasePermission):
             request.method in permissions.SAFE_METHODS or
             request.method in permissions.SAFE_METHODS or
             not request.user.locked
             not request.user.locked
         )
         )
-
-
-class IsUnlockedOrDyn(permissions.BasePermission):
-    """
-    Allow non-safe methods only for unlocked or dynDNS users.
-    """
-    message = IsUnlocked.message
-
-    def has_permission(self, request, view):
-        return bool(
-            request.method in permissions.SAFE_METHODS or
-            request.user.dyn or
-            not request.user.locked
-        )

+ 3 - 0
api/desecapi/renderers.py

@@ -11,6 +11,9 @@ class PlainTextRenderer(renderers.BaseRenderer):
         response = renderer_context.get('response')
         response = renderer_context.get('response')
 
 
         if response and response.exception:
         if response and response.exception:
+            if not isinstance(data, dict) or data.get('detail', None) is None:
+                raise ValueError('Expected response.data to be a dict with error details in response.data[\'detail\'], '
+                                 'but got %s:\n\n%s' % (type(response.data), response.data))
             response['Content-Type'] = 'text/plain'
             response['Content-Type'] = 'text/plain'
             return data['detail']
             return data['detail']
 
 

+ 363 - 164
api/desecapi/serializers.py

@@ -1,16 +1,14 @@
 import re
 import re
 
 
-import django.core.exceptions
-from django.core.validators import RegexValidator
-from django.db import models, transaction
+from django.db.models import Model, Q
 from djoser import serializers as djoser_serializers
 from djoser import serializers as djoser_serializers
 from rest_framework import serializers
 from rest_framework import serializers
-from rest_framework.exceptions import ValidationError
-from rest_framework.fields import empty
+from rest_framework.fields import empty, SkipField, ListField, CharField
+from rest_framework.serializers import ListSerializer
 from rest_framework.settings import api_settings
 from rest_framework.settings import api_settings
-from rest_framework_bulk import BulkListSerializer, BulkSerializerMixin
+from rest_framework.validators import UniqueTogetherValidator
 
 
-from desecapi.models import Domain, Donation, User, RR, RRset, Token
+from desecapi.models import Domain, Donation, User, RRset, Token, RR
 
 
 
 
 class TokenSerializer(serializers.ModelSerializer):
 class TokenSerializer(serializers.ModelSerializer):
@@ -24,37 +22,6 @@ class TokenSerializer(serializers.ModelSerializer):
         read_only_fields = ('created', 'value', 'id')
         read_only_fields = ('created', 'value', 'id')
 
 
 
 
-class RRSerializer(serializers.ModelSerializer):
-    class Meta:
-        model = RR
-        fields = ('content',)
-
-    def to_internal_value(self, data):
-        if not isinstance(data, dict):
-            data = {'content': data}
-        return super().to_internal_value(data)
-
-
-class RRsetBulkListSerializer(BulkListSerializer):
-    default_error_messages = {'not_a_list': 'Invalid input, expected a list of RRsets.'}
-
-    @transaction.atomic
-    def update(self, queryset, validated_data):
-        q = models.Q(pk__isnull=True)
-        for data in validated_data:
-            q |= models.Q(subname=data.get('subname', ''), type=data['type'])
-        rrsets = {(obj.subname, obj.type): obj for obj in queryset.filter(q)}
-        instance = [rrsets.get((data.get('subname', ''), data['type']), None)
-                    for data in validated_data]
-        # noinspection PyUnresolvedReferences,PyProtectedMember
-        return self.child._save(instance, validated_data)
-
-    @transaction.atomic
-    def create(self, validated_data):
-        # noinspection PyUnresolvedReferences,PyProtectedMember
-        return self.child._save([None] * len(validated_data), validated_data)
-
-
 class RequiredOnPartialUpdateCharField(serializers.CharField):
 class RequiredOnPartialUpdateCharField(serializers.CharField):
     """
     """
     This field is always required, even for partial updates (e.g. using PATCH).
     This field is always required, even for partial updates (e.g. using PATCH).
@@ -66,142 +33,171 @@ class RequiredOnPartialUpdateCharField(serializers.CharField):
         return super().validate_empty_values(data)
         return super().validate_empty_values(data)
 
 
 
 
-class SlugRRField(serializers.SlugRelatedField):
-    def __init__(self, *args, **kwargs):
-        kwargs['slug_field'] = 'content'
-        kwargs['queryset'] = RR.objects.all()
-        super().__init__(*args, **kwargs)
+class Validator:
 
 
-    def to_internal_value(self, data):
-        return RR(**{self.slug_field: data})
+    message = 'This field did not pass validation.'
 
 
+    def __init__(self, message=None):
+        self.field_name = None
+        self.message = message or self.message
+        self.instance = None
 
 
-class RRsetSerializer(BulkSerializerMixin, serializers.ModelSerializer):
-    domain = serializers.SlugRelatedField(read_only=True, slug_field='name')
-    subname = serializers.CharField(
-        allow_blank=True,
-        required=False,
-        validators=[RegexValidator(
-            regex=r'^\*?[a-z\.\-_0-9]*$',
-            message='Subname can only use (lowercase) a-z, 0-9, ., -, and _.',
-            code='invalid_subname'
-        )]
-    )
-    type = RequiredOnPartialUpdateCharField(
-        allow_blank=False,
-        required=True,
-        validators=[RegexValidator(
-            regex=r'^[A-Z][A-Z0-9]*$',
-            message='Type must be uppercase alphanumeric and start with a letter.',
-            code='invalid_type'
-        )]
-    )
-    records = SlugRRField(many=True)
+    def __call__(self, value):
+        raise NotImplementedError
 
 
-    class Meta:
-        model = RRset
-        fields = ('id', 'domain', 'subname', 'name', 'records', 'ttl', 'type',)
-        list_serializer_class = RRsetBulkListSerializer
+    def __repr__(self):
+        return '<%s>' % self.__class__.__name__
 
 
-    def _save(self, instance, validated_data):
-        bulk = isinstance(instance, list)
-        if not bulk:
-            instance = [instance]
-            validated_data = [validated_data]
 
 
-        name = self.context['view'].kwargs['name']
-        domain = self.context['request'].user.domains.get(name=name)
-        method = self.context['request'].method
+class ReadOnlyOnUpdateValidator(Validator):
 
 
-        errors = []
-        rrsets = {}
-        rrsets_seen = set()
-        for rrset, data in zip(instance, validated_data):
-            # Construct RRset
-            records = data.pop('records', None)
-            if rrset:
-                # We have a known instance (update). Update fields if given.
-                rrset.subname = data.get('subname', rrset.subname)
-                rrset.type = data.get('type', rrset.type)
-                rrset.ttl = data.get('ttl', rrset.ttl)
-            else:
-                # No known instance (creation)
-                rrset_errors = {}
-                if 'ttl' not in data:
-                    rrset_errors['ttl'] = ['This field is required for new RRsets.']
-                if records is None:
-                    rrset_errors['records'] = ['This field is required for new RRsets.']
-                if rrset_errors:
-                    errors.append(rrset_errors)
-                    continue
-                data.pop('id', None)
-                data['domain'] = domain
-                rrset = RRset(**data)
-
-            # Verify that we have not seen this RRset before
-            if (rrset.subname, rrset.type) in rrsets_seen:
-                errors.append({'__all__': ['RRset repeated with same subname and type.']})
-                continue
-            rrsets_seen.add((rrset.subname, rrset.type))
-
-            # Validate RRset. Raises error if type or subname have been changed
-            # or if new RRset is not unique.
-            validate_unique = (method == 'POST')
-            try:
-                rrset.full_clean(exclude=['updated'],
-                                 validate_unique=validate_unique)
-            except django.core.exceptions.ValidationError as e:
-                errors.append(e.message_dict)
-                continue
-
-            # Construct dictionary of RR lists to write, indexed by their RRset
-            if records is None:
-                rrsets[rrset] = None
-            else:
-                rr_data = [{'content': x.content} for x in records]
+    message = 'Can only be written on create.'
 
 
-                # Use RRSerializer to validate records inputs
-                allow_empty = (method in ('PATCH', 'PUT'))
-                rr_serializer = RRSerializer(data=rr_data, many=True,
-                                             allow_empty=allow_empty)
+    def set_context(self, serializer_field):
+        """
+        This hook is called by the serializer instance,
+        prior to the validation call being made.
+        """
+        self.field_name = serializer_field.source_attrs[-1]
+        self.instance = getattr(serializer_field.parent, 'instance', None)
 
 
-                if not rr_serializer.is_valid():
-                    error = rr_serializer.errors
-                    if api_settings.NON_FIELD_ERRORS_KEY in error:
-                        error['records'] = error.pop(api_settings.NON_FIELD_ERRORS_KEY)
-                    errors.append(error)
-                    continue
+    def __call__(self, value):
+        if isinstance(self.instance, Model) and value != getattr(self.instance, self.field_name):
+            raise serializers.ValidationError(self.message, code='read-only-on-update')
 
 
-                # Blessings have been given, so add RRset to the to-write dict
-                rrsets[rrset] = [RR(rrset=rrset, **rr_validated_data)
-                                 for rr_validated_data in rr_serializer.validated_data]
 
 
-            errors.append({})
+class StringField(CharField):
 
 
-        if any(errors):
-            raise ValidationError(errors if bulk else errors[0])
+    def to_internal_value(self, data):
+        return data
 
 
-        # Now try to save RRsets
+    def run_validation(self, data=empty):
+        data = super().run_validation(data)
+        if not isinstance(data, str):
+            raise serializers.ValidationError('Must be a string.', code='must-be-a-string')
+        return data
+
+
+class RRsField(ListField):
+
+    def __init__(self, **kwargs):
+        super().__init__(child=StringField(), **kwargs)
+
+    def to_representation(self, data):
+        return [rr.content for rr in data.all()]
+
+
+class ConditionalExistenceModelSerializer(serializers.ModelSerializer):
+    """
+    Only considers data with certain condition as existing data.
+    If the existence condition does not hold, given instances are deleted, and no new instances are created,
+    respectively. Also, to_representation and data will return None.
+    Contrary, if the existence condition holds, the behavior is the same as DRF's ModelSerializer.
+    """
+
+    def exists(self, arg):
+        """
+        Determine if arg is to be considered existing.
+        :param arg: Either a model instance or (possibly invalid!) data object.
+        :return: Whether we treat this as non-existing instance.
+        """
+        raise NotImplementedError
+
+    def to_representation(self, instance):
+        return None if not self.exists(instance) else super().to_representation(instance)
+
+    @property
+    def data(self):
         try:
         try:
-            rrsets = domain.write_rrsets(rrsets)
-        except django.core.exceptions.ValidationError as e:
-            for attr in ['errors', 'error_dict', 'message']:
-                detail = getattr(e, attr, None)
-                if detail:
-                    raise ValidationError(detail)
-            raise ValidationError(str(e))
-        except ValueError as e:
-            raise ValidationError({'__all__': str(e)})
-
-        return rrsets if bulk else rrsets[0]
-
-    @transaction.atomic
-    def update(self, instance, validated_data):
-        return self._save(instance, validated_data)
+            return super().data
+        except TypeError:
+            return None
 
 
-    @transaction.atomic
-    def create(self, validated_data):
-        return self._save(None, validated_data)
+    def save(self, **kwargs):
+        validated_data = {}
+        validated_data.update(self.validated_data)
+        validated_data.update(kwargs)
+
+        known_instance = self.instance is not None
+        data_exists = self.exists(validated_data)
+
+        if known_instance and data_exists:
+            self.instance = self.update(self.instance, validated_data)
+        elif known_instance and not data_exists:
+            self.delete()
+        elif not known_instance and data_exists:
+            self.instance = self.create(validated_data)
+        elif not known_instance and not data_exists:
+            pass  # nothing to do
+
+        return self.instance
+
+    def delete(self):
+        self.instance.delete()
+
+
+class NonBulkOnlyDefault:
+    """
+    This class may be used to provide default values that are only used
+    for non-bulk operations, but that do not return any value for bulk
+    operations.
+    Implementation inspired by CreateOnlyDefault.
+    """
+    def __init__(self, default):
+        self.default = default
+
+    def set_context(self, serializer_field):
+        # noinspection PyAttributeOutsideInit
+        self.is_many = getattr(serializer_field.root, 'many', False)
+        if callable(self.default) and hasattr(self.default, 'set_context') and not self.is_many:
+            # noinspection PyUnresolvedReferences
+            self.default.set_context(serializer_field)
+
+    def __call__(self):
+        if self.is_many:
+            raise SkipField()
+        if callable(self.default):
+            return self.default()
+        return self.default
+
+    def __repr__(self):
+        return '%s(%s)' % (self.__class__.__name__, repr(self.default))
+
+
+class RRsetSerializer(ConditionalExistenceModelSerializer):
+    domain = serializers.SlugRelatedField(read_only=True, slug_field='name')
+    records = RRsField(allow_empty=True)
+
+    class Meta:
+        model = RRset
+        fields = ('domain', 'subname', 'name', 'records', 'ttl', 'type',)
+        extra_kwargs = {
+            'subname': {'required': False, 'default': NonBulkOnlyDefault('')}
+        }
+
+    def __init__(self, instance=None, data=empty, domain=None, **kwargs):
+        if domain is None:
+            raise ValueError('RRsetSerializer() must be given a domain object (to validate uniqueness constraints).')
+        self.domain = domain
+        super().__init__(instance, data, **kwargs)
+
+    @classmethod
+    def many_init(cls, *args, **kwargs):
+        domain = kwargs.pop('domain')
+        kwargs['child'] = cls(domain=domain)
+        return RRsetListSerializer(*args, **kwargs)
+
+    def get_fields(self):
+        fields = super().get_fields()
+        fields['subname'].validators.append(ReadOnlyOnUpdateValidator())
+        fields['type'].validators.append(ReadOnlyOnUpdateValidator())
+        return fields
+
+    def get_validators(self):
+        return [UniqueTogetherValidator(
+            self.domain.rrset_set, ('subname', 'type'),
+            message='Another RRset with the same subdomain and type exists for this domain.'
+        )]
 
 
     @staticmethod
     @staticmethod
     def validate_type(value):
     def validate_type(value):
@@ -216,18 +212,221 @@ class RRsetSerializer(BulkSerializerMixin, serializers.ModelSerializer):
                 "Generic type format is not supported.")
                 "Generic type format is not supported.")
         return value
         return value
 
 
-    def to_representation(self, instance):
-        data = super().to_representation(instance)
-        data.pop('id')
-        return data
+    def validate_records(self, value):
+        # `records` is usually allowed to be empty (for idempotent delete), except for POST requests which are intended
+        # for RRset creation only. We use the fact that DRF generic views pass the request in the serializer context.
+        request = self.context.get('request')
+        if request and request.method == 'POST' and not value:
+            raise serializers.ValidationError('This field must not be empty when using POST.')
+        return value
+
+    def exists(self, arg):
+        if isinstance(arg, RRset):
+            return arg.records.exists()
+        else:
+            return bool(arg.get('records')) if 'records' in arg.keys() else True
+
+    def create(self, validated_data):
+        rrs_data = validated_data.pop('records')
+        rrset = RRset.objects.create(**validated_data)
+        self._set_all_record_contents(rrset, rrs_data)
+        return rrset
+
+    def update(self, instance: RRset, validated_data):
+        rrs_data = validated_data.pop('records', None)
+        if rrs_data is not None:
+            self._set_all_record_contents(instance, rrs_data)
+
+        ttl = validated_data.pop('ttl', None)
+        if ttl and instance.ttl != ttl:
+            instance.ttl = ttl
+            instance.save()
+
+        return instance
+
+    @staticmethod
+    def _set_all_record_contents(rrset: RRset, record_contents):
+        """
+        Updates this RR set's resource records, discarding any old values.
+
+        To do so, two large select queries and one query per changed (added or removed) resource record are needed.
+
+        Changes are saved to the database immediately.
+
+        :param rrset: the RRset at which we overwrite all RRs
+        :param record_contents: set of strings
+        """
+        # Remove RRs that we didn't see in the new list
+        removed_rrs = rrset.records.exclude(content__in=record_contents)  # one SELECT
+        for rr in removed_rrs:
+            rr.delete()  # one DELETE query
+
+        # Figure out which entries in record_contents have not changed
+        unchanged_rrs = rrset.records.filter(content__in=record_contents)  # one SELECT
+        unchanged_content = [unchanged_rr.content for unchanged_rr in unchanged_rrs]
+        added_content = filter(lambda c: c not in unchanged_content, record_contents)
+
+        rrs = [RR(rrset=rrset, content=content) for content in added_content]
+        RR.objects.bulk_create(rrs)  # One INSERT
+
+
+class RRsetListSerializer(ListSerializer):
+    default_error_messages = {'not_a_list': 'Invalid input, expected a list of RRsets.'}
+
+    @staticmethod
+    def _key(data_item):
+        return data_item.get('subname', None), data_item.get('type', None)
+
+    def to_internal_value(self, data):
+        if not isinstance(data, list):
+            message = self.error_messages['not_a_list'].format(input_type=type(data).__name__)
+            raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: [message]}, code='not_a_list')
+
+        if not self.allow_empty and len(data) == 0:
+            if self.parent and self.partial:
+                raise SkipField()
+            else:
+                self.fail('empty')
+
+        ret = []
+        errors = []
+        partial = self.partial
+
+        # build look-up objects for instances and data, so we can look them up with their keys
+        try:
+            known_instances = {(x.subname, x.type): x for x in self.instance}
+        except TypeError:  # in case self.instance is None (as during POST)
+            known_instances = {}
+        indices_by_key = {}
+        for idx, item in enumerate(data):
+            items = indices_by_key.setdefault(self._key(item), set())
+            items.add(idx)
+
+        # Iterate over all rows in the data given
+        for idx, item in enumerate(data):
+            try:
+                # see if other rows have the same key
+                if len(indices_by_key[self._key(item)]) > 1:
+                    raise serializers.ValidationError({
+                        '__all__': [
+                            'Same subname and type as in position(s) %s, but must be unique.' %
+                            ', '.join(map(str, indices_by_key[self._key(item)] - {idx}))
+                        ]
+                    })
+
+                # determine if this is a partial update (i.e. PATCH):
+                # we allow partial update if a partial update method (i.e. PATCH) is used, as indicated by self.partial,
+                # and if this is not actually a create request because it is unknown and nonempty
+                unknown = self._key(item) not in known_instances.keys()
+                nonempty = item.get('records', None) != []
+                self.partial = partial and not (unknown and nonempty)
+                self.child.instance = known_instances.get(self._key(item), None)
+
+                # with partial value and instance in place, let the validation begin!
+                validated = self.child.run_validation(item)
+            except serializers.ValidationError as exc:
+                errors.append(exc.detail)
+            else:
+                ret.append(validated)
+                errors.append({})
+
+        self.partial = partial
+
+        if any(errors):
+            raise serializers.ValidationError(errors)
+
+        return ret
+
+    def update(self, instance, validated_data):
+        """
+        Creates, updates and deletes RRsets according to the validated_data given. Relevant instances must be passed as
+        a queryset in the `instance` argument.
+
+        RRsets that appear in `instance` are considered "known", other RRsets are considered "unknown". RRsets that
+        appear in `validated_data` with records == [] are considered empty, otherwise non-empty.
+
+        The update proceeds as follows:
+        1. All unknown, non-empty RRsets are created.
+        2. All known, non-empty RRsets are updated.
+        3. All known, empty RRsets are deleted.
+        4. Unknown, empty RRsets will not cause any action.
+
+        Rationale:
+        As both "known"/"unknown" and "empty"/"non-empty" are binary partitions on `everything`, the combination of
+        both partitions `everything` in four disjoint subsets. Hence, every RRset in `everything` is taken care of.
+
+                   empty   |  non-empty
+        ------- | -------- | -----------
+        known   |  delete  |   update
+        unknown |  no-op   |   create
+
+        :param instance: QuerySet of relevant RRset objects, i.e. the Django.Model subclass instances. Relevant are all
+        instances that are referenced in `validated_data`. If a referenced RRset is missing from instances, it will be
+        considered unknown and hence be created. This may cause a database integrity error. If an RRset is given, but
+        not relevant (i.e. not referred to by `validated_data`), a ValueError will be raised.
+        :param validated_data: List of RRset data objects, i.e. dictionaries.
+        :return: List of RRset objects (Django.Model subclass) that have been created or updated.
+        """
+        def is_empty(data_item):
+            return data_item.get('records', None) == []
+
+        query = Q()
+        for item in validated_data:
+            query |= Q(type=item['type'], subname=item['subname'])  # validation has ensured these fields exist
+        instance = instance.filter(query)
+
+        instance_index = {(rrset.subname, rrset.type): rrset for rrset in instance}
+        data_index = {self._key(data): data for data in validated_data}
+
+        if data_index.keys() | instance_index.keys() != data_index.keys():
+            raise ValueError('Given set of known RRsets (`instance`) is not a subset of RRsets referred to in'
+                             '`validated_data`. While this would produce a correct result, this is illegal due to its'
+                             ' inefficiency.')
+
+        everything = instance_index.keys() | data_index.keys()
+        known = instance_index.keys()
+        unknown = everything - known
+        # noinspection PyShadowingNames
+        empty = {self._key(data) for data in validated_data if is_empty(data)}
+        nonempty = everything - empty
+
+        # noinspection PyUnusedLocal
+        noop = unknown & empty
+        created = unknown & nonempty
+        updated = known & nonempty
+        deleted = known & empty
+
+        ret = []
+        for subname, type_ in created:
+            ret.append(self.child.create(
+                validated_data=data_index[(subname, type_)]
+            ))
+
+        for subname, type_ in updated:
+            ret.append(self.child.update(
+                instance=instance_index[(subname, type_)],
+                validated_data=data_index[(subname, type_)]
+            ))
+
+        for subname, type_ in deleted:
+            instance_index[(subname, type_)].delete()
+
+        return ret
 
 
 
 
 class DomainSerializer(serializers.ModelSerializer):
 class DomainSerializer(serializers.ModelSerializer):
-    name = serializers.RegexField(regex=r'^[a-z0-9_.-]+$', max_length=191, trim_whitespace=False)
 
 
     class Meta:
     class Meta:
         model = Domain
         model = Domain
         fields = ('created', 'published', 'name', 'keys')
         fields = ('created', 'published', 'name', 'keys')
+        extra_kwargs = {
+            'name': {'trim_whitespace': False}
+        }
+
+    def get_fields(self):
+        fields = super().get_fields()
+        fields['name'].validators.append(ReadOnlyOnUpdateValidator())
+        return fields
 
 
 
 
 class DonationSerializer(serializers.ModelSerializer):
 class DonationSerializer(serializers.ModelSerializer):

+ 129 - 88
api/desecapi/tests/base.py

@@ -5,6 +5,7 @@ import re
 import string
 import string
 from contextlib import nullcontext
 from contextlib import nullcontext
 from functools import partial, reduce
 from functools import partial, reduce
+from json import JSONDecodeError
 from typing import Union, List, Dict
 from typing import Union, List, Dict
 from unittest import mock
 from unittest import mock
 
 
@@ -65,7 +66,6 @@ class DesecAPIClient(APIClient):
         )
         )
 
 
     def post_rr_set(self, domain_name, **kwargs):
     def post_rr_set(self, domain_name, **kwargs):
-        kwargs.setdefault('subname', '')
         kwargs.setdefault('ttl', 60)
         kwargs.setdefault('ttl', 60)
         return self.post(
         return self.post(
             self.reverse('v1:rrsets', name=domain_name),
             self.reverse('v1:rrsets', name=domain_name),
@@ -103,14 +103,14 @@ class DesecAPIClient(APIClient):
     # TODO add and use {post,get,delete,...}_domain
     # TODO add and use {post,get,delete,...}_domain
 
 
 
 
-class ReadUncommitted:
+class SQLiteReadUncommitted:
 
 
     def __init__(self):
     def __init__(self):
         self.read_uncommitted = None
         self.read_uncommitted = None
 
 
     def __enter__(self):
     def __enter__(self):
         with connection.cursor() as cursor:
         with connection.cursor() as cursor:
-            cursor.execute('PRAGMA read_uncommitted;')  # FIXME this is probably sqlite only?
+            cursor.execute('PRAGMA read_uncommitted;')
             self.read_uncommitted = True if cursor.fetchone()[0] else False
             self.read_uncommitted = True if cursor.fetchone()[0] else False
             cursor.execute('PRAGMA read_uncommitted = true;')
             cursor.execute('PRAGMA read_uncommitted = true;')
 
 
@@ -296,6 +296,24 @@ class MockPDNSTestCase(APITestCase):
             'body': None,
             'body': None,
         }
         }
 
 
+    def request_pdns_zone_create_assert_name(self, ns, name):
+        def request_callback(r, _, response_headers):
+            body = json.loads(r.parsed_body)
+            self.failIf('name' not in body.keys(),
+                        'pdns domain creation request malformed: did not contain a domain name.')
+
+            try:  # if an assertion fails, an exception is raised. We want to send a reply anyway!
+                self.assertEqual(name, body['name'], 'Expected to see a domain creation request with name %s, '
+                                                     'but name %s was sent.' % (name, body['name']))
+            finally:
+                return [201, response_headers, '']
+
+        request = self.request_pdns_zone_create(ns)
+        request.pop('status')
+        # noinspection PyTypeChecker
+        request['body'] = request_callback
+        return request
+
     @classmethod
     @classmethod
     def request_pdns_zone_create_422(cls):
     def request_pdns_zone_create_422(cls):
         request = cls.request_pdns_zone_create(ns='LORD')
         request = cls.request_pdns_zone_create(ns='LORD')
@@ -368,44 +386,46 @@ class MockPDNSTestCase(APITestCase):
             self.failIf('rrsets' not in body.keys(),
             self.failIf('rrsets' not in body.keys(),
                         'pdns zone update request malformed: did not contain a list of RR sets.')
                         'pdns zone update request malformed: did not contain a list of RR sets.')
 
 
-            with ReadUncommitted():  # tests are wrapped in uncommitted transactions, so we need to see inside
-                # convert updated_rr_sets into a plain data type, if Django models were given
-                if isinstance(updated_rr_sets, list):
-                    updated_rr_sets_dict = {}
-                    for rr_set in updated_rr_sets:
-                        updated_rr_sets_dict[(rr_set.type, rr_set.subname, rr_set.ttl)] = rrs = []
-                        for rr in rr_set.records.all():
-                            rrs.append(rr.content)
-                elif isinstance(updated_rr_sets, dict):
-                    updated_rr_sets_dict = updated_rr_sets
-                else:
-                    raise ValueError('updated_rr_sets must be a list of RRSets or a dict.')
-
-                # check expectations
-                self.assertEqual(len(updated_rr_sets_dict), len(body['rrsets']),
-                                 'Saw an unexpected number of RR set updates: expected %i, intercepted %i.' %
-                                 (len(updated_rr_sets_dict), len(body['rrsets'])))
-                for (expected_type, expected_subname, expected_ttl), expected_records in updated_rr_sets_dict.items():
-                    expected_name = '.'.join(filter(None, [expected_subname, name])) + '.'
-                    for seen_rr_set in body['rrsets']:
-                        if (expected_name == seen_rr_set['name'] and
-                                expected_type == seen_rr_set['type']):
-                            # TODO replace the following asserts by assertTTL, assertRecords, ... or similar
-                            if len(expected_records):
-                                self.assertEqual(expected_ttl, seen_rr_set['ttl'])
-                            self.assertEqual(
-                                set(expected_records),
-                                set([rr['content'] for rr in seen_rr_set['records']]),
-                            )
-                            break
+            try:  # if an assertion fails, an exception is raised. We want to send a reply anyway!
+                with SQLiteReadUncommitted():  # tests are wrapped in uncommitted transactions, so we need to see inside
+                    # convert updated_rr_sets into a plain data type, if Django models were given
+                    if isinstance(updated_rr_sets, list):
+                        updated_rr_sets_dict = {}
+                        for rr_set in updated_rr_sets:
+                            updated_rr_sets_dict[(rr_set.type, rr_set.subname, rr_set.ttl)] = rrs = []
+                            for rr in rr_set.records.all():
+                                rrs.append(rr.content)
+                    elif isinstance(updated_rr_sets, dict):
+                        updated_rr_sets_dict = updated_rr_sets
                     else:
                     else:
-                        # we did not break out, i.e. we did not find a matching RR set in body['rrsets']
-                        self.fail('Expected to see an pdns zone update request for RR set of domain `%s` with name '
-                                  '`%s` and type `%s`, but did not see one. Seen update request on %s for RR sets:'
-                                  '\n\n%s'
-                                  % (name, expected_name, expected_type, request['uri'],
-                                     json.dumps(body['rrsets'], indent=4)))
-            return [200, response_headers, '']
+                        raise ValueError('updated_rr_sets must be a list of RRSets or a dict.')
+
+                    # check expectations
+                    self.assertEqual(len(updated_rr_sets_dict), len(body['rrsets']),
+                                     'Saw an unexpected number of RR set updates: expected %i, intercepted %i.' %
+                                     (len(updated_rr_sets_dict), len(body['rrsets'])))
+                    for (exp_type, exp_subname, exp_ttl), exp_records in updated_rr_sets_dict.items():
+                        expected_name = '.'.join(filter(None, [exp_subname, name])) + '.'
+                        for seen_rr_set in body['rrsets']:
+                            if (expected_name == seen_rr_set['name'] and
+                                    exp_type == seen_rr_set['type']):
+                                # TODO replace the following asserts by assertTTL, assertRecords, ... or similar
+                                if len(exp_records):
+                                    self.assertEqual(exp_ttl, seen_rr_set['ttl'])
+                                self.assertEqual(
+                                    set(exp_records),
+                                    set([rr['content'] for rr in seen_rr_set['records']]),
+                                )
+                                break
+                        else:
+                            # we did not break out, i.e. we did not find a matching RR set in body['rrsets']
+                            self.fail('Expected to see an pdns zone update request for RR set of domain `%s` with name '
+                                      '`%s` and type `%s`, but did not see one. Seen update request on %s for RR sets:'
+                                      '\n\n%s'
+                                      % (name, expected_name, exp_type, request['uri'],
+                                         json.dumps(body['rrsets'], indent=4)))
+            finally:
+                return [200, response_headers, '']
 
 
         request = self.request_pdns_zone_update(name)
         request = self.request_pdns_zone_update(name)
         request.pop('status')
         request.pop('status')
@@ -537,6 +557,12 @@ class MockPDNSTestCase(APITestCase):
                       str(response.data).replace('\\n', '\n') if hasattr(response, 'data') else '',
                       str(response.data).replace('\\n', '\n') if hasattr(response, 'data') else '',
                 ))
                 ))
 
 
+    def assertResponse(self, response, code=None, body=None):
+        if code:
+            self.assertStatus(response, code)
+        if body:
+            self.assertJSONEqual(response.content, body)
+
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         httpretty.enable(allow_net_connect=False)
         httpretty.enable(allow_net_connect=False)
@@ -569,13 +595,18 @@ class MockPDNSTestCase(APITestCase):
 
 
     def setUp(self):
     def setUp(self):
         def request_callback(r, _, response_headers):
         def request_callback(r, _, response_headers):
+            try:
+                request = json.loads(r.parsed_body)
+            except JSONDecodeError:
+                request = r.parsed_body
+
             return [
             return [
                 599,
                 599,
                 response_headers,
                 response_headers,
                 json.dumps(
                 json.dumps(
                     {
                     {
                         'MockPDNSTestCase': 'This response was generated upon an unexpected request.',
                         'MockPDNSTestCase': 'This response was generated upon an unexpected request.',
-                        'request': str(r),
+                        'request': request,
                         'method': str(r.method),
                         'method': str(r.method),
                         'requestline': str(r.raw_requestline),
                         'requestline': str(r.raw_requestline),
                         'host': str(r.headers['Host']) if 'Host' in r.headers else None,
                         'host': str(r.headers['Host']) if 'Host' in r.headers else None,
@@ -617,6 +648,10 @@ class DesecTestCase(MockPDNSTestCase):
     PUBLIC_SUFFIXES = {'de', 'com', 'io', 'gov.cd', 'edu.ec', 'xxx', 'pinb.gov.pl', 'valer.ostfold.no',
     PUBLIC_SUFFIXES = {'de', 'com', 'io', 'gov.cd', 'edu.ec', 'xxx', 'pinb.gov.pl', 'valer.ostfold.no',
                        'kota.aichi.jp', 's3.amazonaws.com', 'wildcard.ck'}
                        'kota.aichi.jp', 's3.amazonaws.com', 'wildcard.ck'}
 
 
+    admin = None
+    auto_delegation_domains = None
+    user = None
+
     @classmethod
     @classmethod
     def reverse(cls, view_name, **kwargs):
     def reverse(cls, view_name, **kwargs):
         return reverse(view_name, kwargs=kwargs)
         return reverse(view_name, kwargs=kwargs)
@@ -626,7 +661,7 @@ class DesecTestCase(MockPDNSTestCase):
         super().setUpTestDataWithPdns()
         super().setUpTestDataWithPdns()
         random.seed(0xde5ec)
         random.seed(0xde5ec)
         cls.admin = cls.create_user(is_admin=True)
         cls.admin = cls.create_user(is_admin=True)
-        cls.add_domains = [cls.create_domain(name=name) for name in cls.AUTO_DELEGATION_DOMAINS]
+        cls.auto_delegation_domains = [cls.create_domain(name=name) for name in cls.AUTO_DELEGATION_DOMAINS]
         cls.user = cls.create_user()
         cls.user = cls.create_user()
 
 
     @classmethod
     @classmethod
@@ -716,7 +751,6 @@ class DesecTestCase(MockPDNSTestCase):
             cls.request_pdns_zone_create(ns='LORD'),
             cls.request_pdns_zone_create(ns='LORD'),
             cls.request_pdns_zone_create(ns='MASTER'),
             cls.request_pdns_zone_create(ns='MASTER'),
             cls.request_pdns_zone_axfr(name=name),
             cls.request_pdns_zone_axfr(name=name),
-            cls.request_pdns_zone_retrieve(name=name),
             cls.request_pdns_zone_retrieve_crypto_keys(name=name),
             cls.request_pdns_zone_retrieve_crypto_keys(name=name),
         ]
         ]
 
 
@@ -740,10 +774,10 @@ class DesecTestCase(MockPDNSTestCase):
     def requests_desec_domain_deletion_auto_delegation(cls, name=None):
     def requests_desec_domain_deletion_auto_delegation(cls, name=None):
         delegate_at = cls._find_auto_delegation_zone(name)
         delegate_at = cls._find_auto_delegation_zone(name)
         return [
         return [
-            cls.request_pdns_zone_update(name=delegate_at),
-            cls.request_pdns_zone_axfr(name=delegate_at),
             cls.request_pdns_zone_delete(name=name, ns='LORD'),
             cls.request_pdns_zone_delete(name=name, ns='LORD'),
             cls.request_pdns_zone_delete(name=name, ns='MASTER'),
             cls.request_pdns_zone_delete(name=name, ns='MASTER'),
+            cls.request_pdns_zone_update(name=delegate_at),
+            cls.request_pdns_zone_axfr(name=delegate_at),
         ]
         ]
 
 
     @classmethod
     @classmethod
@@ -753,6 +787,50 @@ class DesecTestCase(MockPDNSTestCase):
             cls.request_pdns_zone_axfr(name=name),
             cls.request_pdns_zone_axfr(name=name),
         ]
         ]
 
 
+    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 _count_occurrences_by_mask(rr_sets, masks):
+        def _cmp(key, a, b):
+            if key == 'records':
+                a = sorted(a)
+                b = sorted(b)
+            return a == b
+
+        def _filter_rr_sets_by_mask(rr_sets_, mask):
+            return [
+                rr_set for rr_set in rr_sets_
+                if reduce(operator.and_, [_cmp(key, rr_set.get(key, None), value) for key, value in mask.items()])
+            ]
+
+        return [len(_filter_rr_sets_by_mask(rr_sets, mask)) for mask in masks]
+
+    def assertRRSetsCount(self, rr_sets, masks, count=1):
+        actual_counts = self._count_occurrences_by_mask(rr_sets, masks)
+        if not all([actual_count == count for actual_count in actual_counts]):
+            self.fail('Expected to find %i RR set(s) for each of %s, but distribution is %s in %s.' % (
+                count, masks, actual_counts, rr_sets
+            ))
+
+    def assertContainsRRSets(self, rr_sets_haystack, rr_sets_needle):
+        if not all(self._count_occurrences_by_mask(rr_sets_haystack, rr_sets_needle)):
+            self.fail('Expected to find RR sets with %s, but only got %s.' % (
+                rr_sets_needle, rr_sets_haystack
+            ))
+
 
 
 class DomainOwnerTestCase(DesecTestCase):
 class DomainOwnerTestCase(DesecTestCase):
     """
     """
@@ -816,6 +894,12 @@ class DomainOwnerTestCase(DesecTestCase):
             for _ in range(cls.NUM_OTHER_DOMAINS)
             for _ in range(cls.NUM_OTHER_DOMAINS)
         ]
         ]
 
 
+        if cls.DYN:
+            for domain in cls.my_domains + cls.other_domains:
+                parent_domain_name = domain.partition_name()[1]
+                parent_domain = Domain.objects.get(name=parent_domain_name)
+                parent_domain.update_delegation(domain)
+
         cls.my_domain = cls.my_domains[0]
         cls.my_domain = cls.my_domains[0]
         cls.other_domain = cls.other_domains[0]
         cls.other_domain = cls.other_domains[0]
 
 
@@ -942,46 +1026,3 @@ class AuthenticatedRRSetBaseTestCase(DomainOwnerTestCase):
         for domain in [cls.my_rr_set_domain, cls.other_rr_set_domain]:
         for domain in [cls.my_rr_set_domain, cls.other_rr_set_domain]:
             for (subname, type_, records, ttl) in cls._test_rr_sets():
             for (subname, type_, records, ttl) in cls._test_rr_sets():
                 cls.create_rr_set(domain, subname=subname, type=type_, records=records, ttl=ttl)
                 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 _count_occurrences_by_mask(rr_sets, masks):
-        def _cmp(key, a, b):
-            if key == 'records':
-                a = sorted(a)
-                b = sorted(b)
-            return a == b
-
-        def _filter_rr_sets_by_mask(rr_sets_, mask):
-            return [rr_set for rr_set in rr_sets_
-                    if reduce(operator.and_, [_cmp(key, rr_set.get(key, None), value) for key, value in mask.items()])
-            ]
-
-        return [len(_filter_rr_sets_by_mask(rr_sets, mask)) for mask in masks]
-
-    def assertRRSetsCount(self, rr_sets, masks, count=1):
-        actual_counts = self._count_occurrences_by_mask(rr_sets, masks)
-        if not all([actual_count == count for actual_count in actual_counts]):
-            self.fail('Expected to find %i RR set(s) for each of %s, but distribution is %s in %s.' % (
-                count, masks, actual_counts, rr_sets
-            ))
-
-    def assertContainsRRSets(self, rr_sets_haystack, rr_sets_needle):
-        if not all(self._count_occurrences_by_mask(rr_sets_haystack, rr_sets_needle)):
-            self.fail('Expected to find RR sets with %s, but only got %s.' % (
-                rr_sets_needle, rr_sets_haystack
-            ))

+ 51 - 33
api/desecapi/tests/test_domains.py

@@ -6,8 +6,8 @@ from django.core.exceptions import ValidationError
 from psl_dns.exceptions import UnsupportedRule
 from psl_dns.exceptions import UnsupportedRule
 from rest_framework import status
 from rest_framework import status
 
 
-from desecapi.exceptions import PdnsException
 from desecapi.models import Domain
 from desecapi.models import Domain
+from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase, LockedDomainOwnerTestCase
 from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase, LockedDomainOwnerTestCase
 
 
 
 
@@ -41,7 +41,7 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
         ]:
         ]:
             with self.assertPdnsRequests(
             with self.assertPdnsRequests(
                 self.requests_desec_domain_creation(name=name)[:-1]  # no serializer, no cryptokeys API call
                 self.requests_desec_domain_creation(name=name)[:-1]  # no serializer, no cryptokeys API call
-            ):
+            ), PDNSChangeTracker():
                 Domain(owner=self.owner, name=name).save()
                 Domain(owner=self.owner, name=name).save()
 
 
     def test_list_domains(self):
     def test_list_domains(self):
@@ -118,6 +118,15 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
                 self.assertStatus(response, status.HTTP_201_CREATED)
                 self.assertStatus(response, status.HTTP_201_CREATED)
                 self.assertEqual(len(mail.outbox), 0)
                 self.assertEqual(len(mail.outbox), 0)
 
 
+            with self.assertPdnsRequests(self.request_pdns_zone_retrieve_crypto_keys(name)):
+                self.assertStatus(
+                    self.client.get(self.reverse('v1:domain-detail', name=name), {'name': name}),
+                    status.HTTP_200_OK
+                )
+                response = self.client.get_rr_sets(name, type='NS', subname='')
+                self.assertStatus(response, status.HTTP_200_OK)
+                self.assertContainsRRSets(response.data, [dict(subname='', records=settings.DEFAULT_NS, type='NS')])
+
     def test_create_api_known_domain(self):
     def test_create_api_known_domain(self):
         url = self.reverse('v1:domain-list')
         url = self.reverse('v1:domain-list')
 
 
@@ -129,14 +138,25 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
                 response = self.client.post(url, {'name': name})
                 response = self.client.post(url, {'name': name})
                 self.assertStatus(response, status.HTTP_201_CREATED)
                 self.assertStatus(response, status.HTTP_201_CREATED)
             response = self.client.post(url, {'name': name})
             response = self.client.post(url, {'name': name})
-            self.assertStatus(response, status.HTTP_409_CONFLICT)
+            self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
     def test_create_pdns_known_domain(self):
     def test_create_pdns_known_domain(self):
         url = self.reverse('v1:domain-list')
         url = self.reverse('v1:domain-list')
         name = self.random_domain_name()
         name = self.random_domain_name()
         with self.assertPdnsRequests(self.request_pdns_zone_create_already_exists(existing_domains=[name])):
         with self.assertPdnsRequests(self.request_pdns_zone_create_already_exists(existing_domains=[name])):
             response = self.client.post(url, {'name': name})
             response = self.client.post(url, {'name': name})
-            self.assertStatus(response, status.HTTP_409_CONFLICT)
+            self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+
+    def test_create_domain_with_whitespace(self):
+        for name in [
+            ' ' + self.random_domain_name(),
+            self.random_domain_name() + '  ',
+        ]:
+            self.assertResponse(
+                self.client.post(self.reverse('v1:domain-list'), {'name': name}),
+                status.HTTP_400_BAD_REQUEST,
+                {'name': ['Domain name malformed.']},
+            )
 
 
     def test_create_public_suffixes(self):
     def test_create_public_suffixes(self):
         for name in self.PUBLIC_SUFFIXES:
         for name in self.PUBLIC_SUFFIXES:
@@ -146,7 +166,7 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
 
 
     def test_create_domain_under_public_suffix_with_private_parent(self):
     def test_create_domain_under_public_suffix_with_private_parent(self):
         name = 'amazonaws.com'
         name = 'amazonaws.com'
-        with self.assertPdnsRequests(self.requests_desec_domain_creation(name)[:-1]):
+        with self.assertPdnsRequests(self.requests_desec_domain_creation(name)[:-1]), PDNSChangeTracker():
             Domain(owner=self.create_user(), name=name).save()
             Domain(owner=self.create_user(), name=name).save()
             self.assertTrue(Domain.objects.filter(name=name).exists())
             self.assertTrue(Domain.objects.filter(name=name).exists())
 
 
@@ -176,7 +196,7 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
         name = '*.' + self.random_domain_name()
         name = '*.' + self.random_domain_name()
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertTrue("does not match the required pattern." in response.data['name'][0])
+        self.assertTrue("Domain name malformed." in response.data['name'][0])
 
 
     def test_create_domain_other_parent(self):
     def test_create_domain_other_parent(self):
         name = 'something.' + self.other_domain.name
         name = 'something.' + self.other_domain.name
@@ -227,21 +247,25 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
 class LockedDomainOwnerTestCase1(LockedDomainOwnerTestCase):
 class LockedDomainOwnerTestCase1(LockedDomainOwnerTestCase):
 
 
     def test_create_domains(self):
     def test_create_domains(self):
-        self.assertStatus(
-            self.client.post(self.reverse('v1:domain-list'), {'name': self.random_domain_name()}),
-            status.HTTP_403_FORBIDDEN
-        )
+        name = self.random_domain_name()
+        with self.assertPdnsRequests(self.requests_desec_domain_creation(name)):
+            self.assertStatus(
+                self.client.post(self.reverse('v1:domain-list'), {'name': name}),
+                status.HTTP_201_CREATED
+            )
 
 
     def test_update_domains(self):
     def test_update_domains(self):
         url = self.reverse('v1:domain-detail', name=self.my_domain.name)
         url = self.reverse('v1:domain-detail', name=self.my_domain.name)
-        data = {'name': self.random_domain_name()}
+        name = self.random_domain_name()
 
 
         for method in [self.client.patch, self.client.put]:
         for method in [self.client.patch, self.client.put]:
-            response = method(url, data)
-            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+            with PDNSChangeTracker():
+                response = method(url, {'name': name})
+                self.assertStatus(response, status.HTTP_400_BAD_REQUEST)  # TODO fix docs, consider to change code
 
 
-        response = self.client.delete(url)
-        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+        with self.assertPdnsRequests(self.requests_desec_domain_deletion(name=self.my_domain.name)):
+            response = self.client.delete(url)
+            self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
 
     def test_create_rr_sets(self):
     def test_create_rr_sets(self):
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
@@ -331,22 +355,16 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
 class LockedAutoDelegationDomainOwnerTests(LockedDomainOwnerTestCase):
 class LockedAutoDelegationDomainOwnerTests(LockedDomainOwnerTestCase):
     DYN = True
     DYN = True
 
 
-    def test_unlock_user(self):
-        name = self.random_domain_name(self.AUTO_DELEGATION_DOMAINS)
-
-        # Users should be able to create domains under auto delegated domains even when locked
-        response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
-        self.assertStatus(response, status.HTTP_201_CREATED)
-
-        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 '" + name + ".' already exists")
+    def test_create_domain(self):
+        name = self.random_domain_name(suffix=self.AUTO_DELEGATION_DOMAINS)
+        with self.assertPdnsRequests(self.requests_desec_domain_creation_auto_delegation(name)):
+            self.assertStatus(
+                self.client.post(self.reverse('v1:domain-list'), {'name': name}),
+                status.HTTP_201_CREATED
+            )
 
 
-        # See what happens upon unlock if this domain is new to pdns
-        with self.assertPdnsRequests(
-                self.requests_desec_domain_creation_auto_delegation(name=name)[:-1]  # No crypto keys retrieved
-        ):
-            self.owner.unlock()
+    def test_create_rrset(self):
+        self.assertStatus(
+            self.client.post_rr_set(self.my_domain.name, type='A', records=['1.1.1.1']),
+            status.HTTP_403_FORBIDDEN
+        )

+ 8 - 12
api/desecapi/tests/test_dyndns12update.py

@@ -7,20 +7,16 @@ from desecapi.tests.base import DynDomainOwnerTestCase
 
 
 class DynDNS12UpdateTest(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_))
-
-        if content:
-            self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data['records'][0], content)
-            self.assertEqual(response.data['ttl'], 60)
-        else:
-            self.assertStatus(response, status.HTTP_404_NOT_FOUND)
-
     def assertIP(self, ipv4=None, ipv6=None, name=None):
     def assertIP(self, ipv4=None, ipv6=None, name=None):
         name = name or self.my_domain.name.lower()
         name = name or self.my_domain.name.lower()
-        self.assertRRSet(name, '', 'A', ipv4)
-        self.assertRRSet(name, '', 'AAAA', ipv6)
+        for type_, value in [('A', ipv4), ('AAAA', ipv6)]:
+            response = self.client_token_authorized.get(self.reverse('v1:rrset', name=name, subname='', type=type_))
+            if value:
+                self.assertStatus(response, status.HTTP_200_OK)
+                self.assertEqual(response.data['records'][0], value)
+                self.assertEqual(response.data['ttl'], 60)
+            else:
+                self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
     def test_identification_by_domain_name(self):
     def test_identification_by_domain_name(self):
         self.client.set_credentials_basic_auth(self.my_domain.name + '.invalid', self.token.key)
         self.client.set_credentials_basic_auth(self.my_domain.name + '.invalid', self.token.key)

+ 517 - 0
api/desecapi/tests/test_pdns_change_tracker.py

@@ -0,0 +1,517 @@
+from django.utils import timezone
+
+from desecapi.models import RRset, RR, Domain
+from desecapi.pdns_change_tracker import PDNSChangeTracker
+from desecapi.tests.base import DesecTestCase
+
+
+class PdnsChangeTrackerTestCase(DesecTestCase):
+
+    empty_domain = None
+    simple_domain = None
+    full_domain = None
+
+    @classmethod
+    def setUpTestDataWithPdns(cls):
+        super().setUpTestDataWithPdns()
+        cls.empty_domain = Domain.objects.create(owner=cls.user, name=cls.random_domain_name())
+        cls.simple_domain = Domain.objects.create(owner=cls.user, name=cls.random_domain_name())
+        cls.full_domain = Domain.objects.create(owner=cls.user, name=cls.random_domain_name())
+
+    def assertPdnsZoneUpdate(self, name, rr_sets):
+        return self.assertPdnsRequests([
+            self.request_pdns_zone_update_assert_body(name, rr_sets),
+            self.request_pdns_zone_axfr(name),
+        ])
+
+
+class RRTestCase(PdnsChangeTrackerTestCase):
+    """
+    Base-class for checking change tracker behavior for all create, update, and delete operations of the RR model.
+    """
+    NUM_OWNED_DOMAINS = 3
+
+    SUBNAME = 'my_rr_set'
+    TYPE = 'A'
+    TTL = 334
+    CONTENT_VALUES = ['2.130.250.238', '170.95.95.252', '128.238.1.5']
+    ALT_CONTENT_VALUES = ['190.169.34.46', '216.228.24.25', '151.138.61.173']
+
+    @classmethod
+    def setUpTestDataWithPdns(cls):
+        super().setUpTestDataWithPdns()
+
+        rr_set_data = dict(subname=cls.SUBNAME, type=cls.TYPE, ttl=cls.TTL)
+        cls.empty_rr_set = RRset.objects.create(domain=cls.empty_domain, **rr_set_data)
+        cls.simple_rr_set = RRset.objects.create(domain=cls.simple_domain, **rr_set_data)
+        cls.full_rr_set = RRset.objects.create(domain=cls.full_domain, **rr_set_data)
+
+        RR.objects.create(rrset=cls.simple_rr_set, content=cls.CONTENT_VALUES[0])
+        for content in cls.CONTENT_VALUES:
+            RR.objects.create(rrset=cls.full_rr_set, content=content)
+
+    def assertPdnsEmptyRRSetUpdate(self):
+        return self.assertPdnsZoneUpdate(self.empty_domain.name, [self.empty_rr_set])
+
+    def assertPdnsSimpleRRSetUpdate(self):
+        return self.assertPdnsZoneUpdate(self.simple_domain.name, [self.simple_rr_set])
+
+    def assertPdnsFullRRSetUpdate(self):
+        return self.assertPdnsZoneUpdate(self.full_domain.name, [self.full_rr_set])
+
+    def test_create_in_empty_rr_set(self):
+        with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
+            RR(content=self.CONTENT_VALUES[0], rrset=self.empty_rr_set).save()
+
+    def test_create_in_simple_rr_set(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            RR(content=self.CONTENT_VALUES[1], rrset=self.simple_rr_set).save()
+
+    def test_create_in_full_rr_set(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            RR(content=self.ALT_CONTENT_VALUES, rrset=self.full_rr_set).save()
+
+    def test_create_multiple_in_empty_rr_set(self):
+        with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
+            for content in self.ALT_CONTENT_VALUES:
+                RR(content=content, rrset=self.empty_rr_set).save()
+
+    def test_create_multiple_in_simple_rr_set(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            for content in self.ALT_CONTENT_VALUES:
+                RR(content=content, rrset=self.simple_rr_set).save()
+
+    def test_create_multiple_in_full_rr_set(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            for content in self.ALT_CONTENT_VALUES:
+                RR(content=content, rrset=self.full_rr_set).save()
+
+    def test_update_simple_rr_set(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            rr = self.simple_rr_set.records.all()[0]
+            rr.content = self.CONTENT_VALUES[1]
+            rr.save()
+
+    def test_update_full_rr_set_partially(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            rr = self.full_rr_set.records.all()[0]
+            rr.content = self.ALT_CONTENT_VALUES[0]
+            rr.save()
+
+    def test_update_full_rr_set_completely(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            for i, rr in enumerate(self.full_rr_set.records.all()):
+                rr.content = self.ALT_CONTENT_VALUES[i]
+                rr.save()
+
+    def test_delete_simple_rr_set(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            self.simple_rr_set.records.all()[0].delete()
+
+    def test_delete_full_rr_set_partially(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            for rr in self.full_rr_set.records.all()[1:2]:
+                rr.delete()
+
+    def test_delete_full_rr_set_completely(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            for rr in self.full_rr_set.records.all():
+                rr.delete()
+
+    def test_create_delete_empty_rr_set(self):
+        with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
+            new_rr = RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[0])
+            RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[1])
+            new_rr.delete()
+
+    def test_create_delete_simple_rr_set_1(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            new_rr = RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0])
+            RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[1])
+            new_rr.delete()
+
+    def test_create_delete_simple_rr_set_2(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            self.simple_rr_set.records.all()[0].delete()
+            RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0])
+
+    def test_create_delete_simple_rr_set_3(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            self.simple_rr_set.records.all()[0].delete()
+            for content in self.ALT_CONTENT_VALUES:
+                RR.objects.create(rrset=self.simple_rr_set, content=content)
+
+    def test_create_delete_full_rr_set_full_replacement(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            for rr in self.full_rr_set.records.all():
+                rr.delete()
+            for content in self.CONTENT_VALUES:
+                RR.objects.create(rrset=self.full_rr_set, content=content)
+
+    def test_create_delete_full_rr_set_partial_replacement(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            self.full_rr_set.records.all()[1].delete()
+            for content in self.ALT_CONTENT_VALUES[1:]:
+                RR.objects.create(rrset=self.full_rr_set, content=content)
+
+    def test_create_update_empty_rr_set_1(self):
+        with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
+            rr = RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[0])
+            rr.content = self.ALT_CONTENT_VALUES[0]
+            rr.save()
+
+    def test_create_update_empty_rr_set_2(self):
+        with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
+            for (content, alt_content) in zip(self.CONTENT_VALUES, self.ALT_CONTENT_VALUES):
+                rr = RR.objects.create(rrset=self.empty_rr_set, content=content)
+                rr.content = alt_content
+                rr.save()
+
+    def test_create_update_empty_rr_set_3(self):
+        with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
+            rr = RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[0])
+            RR.objects.create(rrset=self.empty_rr_set, content=self.ALT_CONTENT_VALUES[1])
+            rr.content = self.CONTENT_VALUES[0]
+            rr.save()
+
+    def test_create_update_simple_rr_set(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            rr = self.simple_rr_set.records.all()[0]
+            RR.objects.create(rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0])
+            rr.content = self.ALT_CONTENT_VALUES[1]
+            rr.save()
+
+    def test_create_update_full_rr_set(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            for i, rr in enumerate(self.full_rr_set.records.all()):
+                rr.content = self.ALT_CONTENT_VALUES[i]
+                rr.save()
+            RR.objects.create(rrset=self.full_rr_set, content=self.CONTENT_VALUES[0])
+
+    def test_update_delete_simple_rr_set(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            rr = self.simple_rr_set.records.all()[0]
+            rr.content = self.ALT_CONTENT_VALUES[0]
+            rr.save()
+            rr.delete()
+
+    def test_update_delete_full_rr_set(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            rr = self.full_rr_set.records.all()[0]
+            rr.content = self.ALT_CONTENT_VALUES[0]
+            rr.save()
+            rr.delete()
+            self.full_rr_set.records.all()[1].delete()
+            rr = self.full_rr_set.records.all()[0]
+            rr.content = self.ALT_CONTENT_VALUES[0]
+            rr.save()
+
+    def test_create_update_delete_empty_rr_set_1(self):
+        rr = RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[0])
+        rr.content = self.ALT_CONTENT_VALUES[0]
+        rr.save()
+        rr.delete()
+
+    def test_create_update_delete_empty_rr_set_2(self):
+        with self.assertPdnsEmptyRRSetUpdate(), PDNSChangeTracker():
+            RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[0])
+            rr = RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[1])
+            rr.content = self.ALT_CONTENT_VALUES[1]
+            rr.save()
+            RR.objects.create(rrset=self.empty_rr_set, content=self.CONTENT_VALUES[2])
+            rr.delete()
+
+    def test_create_update_delete_simple_rr_set(self):
+        with self.assertPdnsSimpleRRSetUpdate(), PDNSChangeTracker():
+            self.simple_rr_set.records.all()[0].delete()
+            RR.objects.create(rrset=self.simple_rr_set, content=self.CONTENT_VALUES[0])
+            rr = RR.objects.create(rrset=self.simple_rr_set, content=self.CONTENT_VALUES[1])
+            rr.content = self.ALT_CONTENT_VALUES[1]
+            rr.save()
+
+    def test_create_update_delete_full_rr_set(self):
+        with self.assertPdnsFullRRSetUpdate(), PDNSChangeTracker():
+            self.full_rr_set.records.all()[1].delete()
+            rr = self.full_rr_set.records.all()[1]
+            rr.content = self.ALT_CONTENT_VALUES[0]
+            rr.save()
+            RR.objects.create(rrset=self.full_rr_set, content=self.ALT_CONTENT_VALUES[1])
+
+
+class AAAARRTestCase(RRTestCase):
+    SUBNAME = '*.foobar'
+    TYPE = 'AAAA'
+    TTL = 12
+    CONTENT_VALUES = ['2001:fb24:45fd:d51:7937:b375:9cf3:5c62', '2001:ed06:5ebc:9d:87a:ce9f:1ceb:996',
+                      '2001:aa22:60e8:cec5:5650:9ff9:9a1b:b588', '2001:3ca:d710:52c2:9748:eec6:2e20:af0b',
+                      '2001:9c6e:8417:3c06:dd1c:44f1:a35f:ffad', '2001:f67a:5847:8dc0:edc3:56f3:a067:f80e',
+                      '2001:4e21:bda6:a509:e777:91c6:2dc1:394', '2001:9930:b062:c38f:99f6:ce12:bb04:f7c6',
+                      '2001:bb5e:921:b17f:7c9b:afb6:9933:cc79', '2001:a861:7139:e21e:11e4:8782:242b:e2a2',
+                      '2001:eaa:ff53:c819:93e:437c:ccc8:330c', '2001:6a88:fb92:5b43:984b:b729:393b:f173']
+    ALT_CONTENT_VALUES = ['2001:2d03:6247:3494:b92e:d4a:2827:e2d', '2001:4b37:19d6:b66e:1aa1:db0f:98b5:d065',
+                          '2001:dbf1:e401:ace2:bc99:eb22:6e12:ec81', '2001:fa92:3564:7c3f:9995:2068:58bf:2a45',
+                          '2001:4c2c:c671:9f0c:600e:4eb6:672e:48c7', '2001:5d09:a6f7:594b:afa4:318a:6eda:3ec6',
+                          '2001:f33a:407c:f4e6:f886:dce2:6d08:d8ae', '2001:43c8:378d:7d37:92eb:fb0c:26b1:4998',
+                          '2001:7293:88c5:5405:fd1:7334:bb55:be20', '2001:c4b7:ae76:a9a2:ffb5:ba30:6874:a416',
+                          '2001:175f:7880:ef82:b65a:a472:14c9:a495', '2001:8c35:1566:4f53:c26a:c54:2c9f:1463']
+
+
+class TXTRRTestCase(RRTestCase):
+    SUBNAME = '_acme_challenge'
+    TYPE = 'TXT'
+    TTL = 876
+    CONTENT_VALUES = ['"The quick brown fox jumps over the lazy dog"',
+                      '"main( ) {printf(\"hello, world\n\");}"',
+                      '“红色联合”对“四·二八兵团”总部大楼的攻击已持续了两天"']
+    ALT_CONTENT_VALUES = ['"🧥 👚 👕 👖 👔 👗 👙 👘 👠 👡 👢 👞 👟 🥾 🥿 🧦 🧤 🧣 🎩 🧢 👒 🎓 ⛑ 👑 👝 👛 👜 💼 🎒 👓 🕶 🥽 🥼 🌂 🧵"',
+                          '"v=spf1 ip4:192.0.2.0/24 ip4:198.51.100.123 a -all"',
+                          '"https://en.wikipedia.org/wiki/Domain_Name_System"']
+
+
+class RRSetTestCase(PdnsChangeTrackerTestCase):
+    TEST_DATA = {
+        ('A', '_asdf', 123): ['1.2.3.4', '5.5.5.5'],
+        ('TXT', 'test', 455): ['ASDF', 'foobar', '92847'],
+        ('A', 'foo', 1010): ['1.2.3.4', '5.5.4.5'],
+        ('AAAA', '*', 100023): ['::1', '::2', '::3', '::4'],
+    }
+
+    ADDITIONAL_TEST_DATA = {
+        ('A', 'zekdi', 99): ['134.48.204.28', '151.85.162.150', '5.174.133.123', '96.37.218.195', '106.18.66.163',
+                             '51.75.149.213', '9.105.0.185', '32.198.60.88', '93.141.131.151', '6.133.10.124'],
+        ('A', 'knebq', 82): ['218.154.60.184'],
+    }
+
+    @classmethod
+    def _create_rr_sets(cls, data, domain):
+        rr_sets = []
+        rrs = {}
+        for (type_, subname, ttl), rr_contents in data.items():
+            rr_set = RRset(domain=domain, subname=subname, type=type_, ttl=ttl)
+            rr_sets.append(rr_set)
+            rrs[(type_, subname)] = this_rrs = []
+            rr_set.save()
+            for content in rr_contents:
+                rr = RR(content=content, rrset=rr_set)
+                this_rrs.append(rr)
+                rr.save()
+        return rr_sets, rrs
+
+    @classmethod
+    def setUpTestDataWithPdns(cls):
+        super().setUpTestDataWithPdns()
+        cls.rr_sets, cls.rrs = cls._create_rr_sets(cls.TEST_DATA, cls.full_domain)
+
+    def test_empty_domain_create_single_empty(self):
+        with PDNSChangeTracker():
+            RRset.objects.create(domain=self.empty_domain, subname='', ttl=60, type='A')
+
+    def test_empty_domain_create_single_meaty(self):
+        with self.assertPdnsZoneUpdate(self.empty_domain.name, self.empty_domain.rrset_set), PDNSChangeTracker():
+            self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.empty_domain)
+
+    def test_full_domain_create_single_empty(self):
+        with PDNSChangeTracker():
+            RRset.objects.create(domain=self.full_domain, subname='', ttl=60, type='A')
+
+    def test_empty_domain_create_many_empty(self):
+        with PDNSChangeTracker():
+            empty_test_data = {key: [] for key, value in self.TEST_DATA.items()}
+            self._create_rr_sets(empty_test_data, self.empty_domain)
+
+    def test_empty_domain_create_many_meaty(self):
+        with self.assertPdnsZoneUpdate(self.empty_domain.name, self.empty_domain.rrset_set), PDNSChangeTracker():
+            self._create_rr_sets(self.TEST_DATA, self.empty_domain)
+
+    def test_empty_domain_delete(self):
+        with PDNSChangeTracker():
+            self._create_rr_sets(self.TEST_DATA, self.empty_domain)
+            for rr_set in self.empty_domain.rrset_set.all():
+                rr_set.delete()
+
+    def test_full_domain_delete_single(self):
+        index = (self.rr_sets[0].type, self.rr_sets[0].subname, self.rr_sets[0].ttl)
+        with self.assertPdnsZoneUpdate(self.full_domain.name, [self.TEST_DATA[index]]), PDNSChangeTracker():
+            self.rr_sets[0].delete()
+
+    def test_full_domain_delete_multiple(self):
+        data = self.TEST_DATA
+        empty_data = {key: [] for key, value in data.items()}
+        with self.assertPdnsZoneUpdate(self.full_domain.name, empty_data), PDNSChangeTracker():
+            for type_, subname, _ in data.keys():
+                self.full_domain.rrset_set.get(subname=subname, type=type_).delete()
+
+    def test_update_type(self):
+        with PDNSChangeTracker():
+            self.rr_sets[0].type = 'PTR'
+            self.rr_sets[0].save()
+
+    def test_update_subname(self):
+        with PDNSChangeTracker():
+            self.rr_sets[0].subname = '*.baz.foobar.ugly'
+            self.rr_sets[0].save()
+
+    def test_update_ttl(self):
+        new_ttl = 765
+        data = {(type_, subname, new_ttl): records for (type_, subname, _), records in self.TEST_DATA.items()}
+        with self.assertPdnsZoneUpdate(self.full_domain.name, data), PDNSChangeTracker():
+            for rr_set in self.full_domain.rrset_set.all():
+                rr_set.ttl = new_ttl
+                rr_set.save()
+
+    def test_full_domain_create_delete(self):
+        data = self.TEST_DATA
+        empty_data = {key: [] for key, value in data.items()}
+        with self.assertPdnsZoneUpdate(self.full_domain.name, empty_data), PDNSChangeTracker():
+            self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.full_domain)
+            for type_, subname, _ in data.keys():
+                self.full_domain.rrset_set.get(subname=subname, type=type_).delete()
+
+
+class CommonRRSetTestCase(RRSetTestCase):
+
+    def test_mixed_operations(self):
+        with self.assertPdnsZoneUpdate(self.full_domain.name, self.ADDITIONAL_TEST_DATA), PDNSChangeTracker():
+            self._create_rr_sets(self.ADDITIONAL_TEST_DATA, self.full_domain)
+
+        rr_sets = [
+            RRset.objects.get(type=type_, subname=subname)
+            for (type_, subname, _) in self.ADDITIONAL_TEST_DATA.keys()
+        ]
+        with self.assertPdnsZoneUpdate(self.full_domain.name, rr_sets), PDNSChangeTracker():
+            for rr_set in rr_sets:
+                rr_set.ttl = 1
+                rr_set.save()
+
+        data = {}
+        for key in [('A', '_asdf', 123), ('AAAA', '*', 100023), ('A', 'foo', 1010)]:
+            data[key] = self.TEST_DATA[key].copy()
+
+        with self.assertPdnsZoneUpdate(self.full_domain.name, data), PDNSChangeTracker():
+            data[('A', '_asdf', 123)].append('9.9.9.9')
+            rr_set = RRset.objects.get(domain=self.full_domain, type='A', subname='_asdf')
+            RR(content='9.9.9.9', rrset=rr_set).save()
+
+            data[('AAAA', '*', 100023)].append('::9')
+            rr_set = RRset.objects.get(domain=self.full_domain, type='AAAA', subname='*')
+            RR(content='::9', rrset=rr_set).save()
+
+            data[('A', 'foo', 1010)] = []
+            RRset.objects.get(domain=self.full_domain, type='A', subname='foo').delete()
+
+
+class UncommonRRSetTestCase(RRSetTestCase):
+    TEST_DATA = {
+        ('SPF', 'baz', 444): ['"v=spf1 ip4:192.0.2.0/24 ip4:198.51.100.123 a -all"',
+                              '"v=spf1 a mx ip4:192.0.2.0 -all"'],
+        ('OPENPGPKEY', '00d8d3f11739d2f3537099982b4674c29fc59a8fda350fca1379613a._openpgpkey', 78000): [
+            'mQENBFnVAMgBCADWXo3I9Vig02zCR8WzGVN4FUrexZh9OdVSjOeSSmXPH6V5'
+            '+sWRfgSvtUp77IWQtZU810EI4GgcEzg30SEdLBSYZAt/lRWSpcQWnql4LvPg'
+            'oMqU+/+WUxFdnbIDGCMEwWzF2NtQwl4r/ot/q5SHoaA4AGtDarjA1pbTBxza'
+            '/xh6VRQLl5vhWRXKslh/Tm4NEBD16Z9gZ1CQ7YlAU5Mg5Io4ghOnxWZCGJHV'
+            '5BVQTrzzozyILny3e48dIwXJKgcFt/DhE+L9JTrO4cYtkG49k7a5biMiYhKh'
+            'LK3nvi5diyPyHYQfUaD5jO5Rfcgwk7L4LFinVmNllqL1mgoxadpgPE8xABEB'
+            'AAG0MUpvaGFubmVzIFdlYmVyIChPTkxZLVRFU1QpIDxqb2hhbm5lc0B3ZWJl'
+            'cmRucy5kZT6JATgEEwECACIFAlnVAMgCGwMGCwkIBwMCBhUIAgkKCwQWAgMB'
+            'Ah4BAheAAAoJEOvytPeP0jpogccH/1IQNza/JPiQRFLWwzz1mxOSgRgubkOw'
+            '+XgXAtvIGHQOF6/ZadQ8rNrMb3D+dS4bTkwpFemY59Bm3n12Ve2Wv2AdN8nK'
+            '1KLClA9cP8380CT53+zygV+mGfoRBLRO0i4QmW3mI6yg7T2E+U20j/i9IT1K'
+            'ATg4oIIgLn2bSpxRtuSp6aJ2q91Y/lne7Af7KbKq/MirEDeSPrjMYxK9D74E'
+            'ABLs4Ab4Rebg3sUga037yTOCYDpRv2xkyARoXMWYlRqME/in7aBtfo/fduJG'
+            'qu2RlND4inQmV75V+s4/x9u+7UlyFIMbWX2rtdWHsO/t4sCP1hhTZxz7kvK7'
+            '1ZqLj9hVjdW5AQ0EWdUAyAEIAKxTR0AcpiDm4r4Zt/qGD9P9jasNR0qkoHjr'
+            '9tmkaW34Lx7wNTDbSYQwn+WFzoT1rxbpge+IpjMn5KabHc0vh13vO1zdxvc0'
+            'LSydhjMI1Gfey+rsQxhT4p5TbvKpsWiNykSNryl1LRgRvcWMnxvYfxdyqIF2'
+            '3+3pgMipXlfJHX4SoAuPn4Bra84y0ziljrptWf4U78+QonX9dwwZ/SCrSPfQ'
+            'rGwpQcHSbbxZvxmgxeweHuAEhUGVuwkFsNBSk4NSi+7Y1p0/oD7tEM17WjnO'
+            'NuoGCFh1anTS7+LE0f3Mp0A74GeJvnkgdnPHJwcZpBf5Jf1/6Nw/tJpYiP9v'
+            'Fu1nF9EAEQEAAYkBHwQYAQIACQUCWdUAyAIbDAAKCRDr8rT3j9I6aDZrB/9j'
+            '2sgCohhDBr/Yzxlg3OmRwnvJlHjs//57XV99ssWAg142HxMQt87s/AXpIuKH'
+            'tupEAClN/knrmKubO3JUkoi3zCDkFkSgrH2Mos75KQbspUtmzwVeGiYSNqyG'
+            'pEzh5UWYuigYx1/a5pf3EhXCVVybIJwxDEo6sKZwYe6CRe5fQpY6eqZNKjkl'
+            '4xDogTMpsrty3snjZHOsQYlTlFWFsm1KA43Mnaj7Pfn35+8bBeNSgiS8R+EL'
+            'f66Ymcl9YHWHHTXjs+DvsrimYbs1GXOyuu3tHfKlZH19ZevXbycpp4UFWsOk'
+            'Sxsb3CZRnPxuz+NjZrOk3UNI6RxlaeuAQOBEow50'],
+        ('PTR', 'foo', 1010): ['1.example.com.', '2.example.com.'],
+        ('SRV', '*', 100023): ['10 60 5060 1.example.com.', '20 60 5060 2.example.com.', '30 60 5060 3.example.com.'],
+        ('TLSA', '_443._tcp.www', 89): ['3 0 1 221C1A9866C32A45E44F55F611303242082A01C1B5C3027C8C7AD1324DE0AC38'],
+    }
+
+
+class DomainTestCase(PdnsChangeTrackerTestCase):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.full_domain = None
+        self.simple_domain = None
+        self.empty_domain = None
+        self.domains = []
+
+    def setUp(self):
+        self.empty_domain = Domain.objects.create(name=self.random_domain_name(), owner=self.user)
+        self.simple_domain = Domain.objects.create(name=self.random_domain_name(), owner=self.user)
+        self.full_domain = Domain.objects.create(name=self.random_domain_name(), owner=self.user)
+        self.domains = [self.empty_domain, self.simple_domain, self.full_domain]
+
+        simple_rr_set = RRset.objects.create(domain=self.simple_domain, type='AAAA', subname='', ttl=42)
+        RR.objects.create(content='::1', rrset=simple_rr_set)
+        RR.objects.create(content='::2', rrset=simple_rr_set)
+
+        rr_set_1 = RRset.objects.create(domain=self.full_domain, type='A', subname='*', ttl=1337)
+        for content in [self.random_ip(4) for _ in range(10)]:
+            RR.objects.create(content=content, rrset=rr_set_1)
+        rr_set_2 = RRset.objects.create(domain=self.full_domain, type='AAAA', subname='', ttl=60)
+        for content in [self.random_ip(6) for _ in range(15)]:
+            RR.objects.create(content=content, rrset=rr_set_2)
+
+    def test_create(self):
+        name = self.random_domain_name()
+        with self.assertPdnsRequests(
+                [
+                    self.request_pdns_zone_create('LORD'),
+                    self.request_pdns_zone_create('MASTER'),
+                    self.request_pdns_zone_axfr(name)
+                ]), PDNSChangeTracker():
+            Domain.objects.create(name=name, owner=self.user)
+
+    def test_update_domain(self):
+        for domain in self.domains:
+            with PDNSChangeTracker():
+                domain.owner = self.admin
+                domain.published = timezone.now()
+                domain.save()
+
+    def test_update_empty_domain_name(self):
+        new_name = self.random_domain_name()
+        with PDNSChangeTracker():  # no exception, no requests
+            self.empty_domain.name = new_name
+            self.empty_domain.save()
+
+    def test_delete_single(self):
+        for domain in self.domains:
+            with self.assertPdnsRequests(self.requests_desec_domain_deletion(domain.name)), PDNSChangeTracker():
+                domain.delete()
+
+    def test_delete_multiple(self):
+        with self.assertPdnsRequests([
+            self.requests_desec_domain_deletion(domain.name) for domain in reversed(self.domains)
+        ], expect_order=False), PDNSChangeTracker():
+            for domain in self.domains:
+                domain.delete()
+
+    def test_create_delete(self):
+        with PDNSChangeTracker():
+            d = Domain.objects.create(name=self.random_domain_name(), owner=self.user)
+            d.delete()
+
+    def test_delete_create_empty_domain(self):
+        with PDNSChangeTracker():
+            name = self.empty_domain.name
+            self.empty_domain.delete()
+            self.empty_domain = Domain.objects.create(name=name, owner=self.user)
+
+    def test_delete_create_full_domain(self):
+        name = self.full_domain.name
+        with self.assertPdnsZoneUpdate(name, []), PDNSChangeTracker():
+            self.full_domain.delete()
+            self.full_domain = Domain.objects.create(name=name, owner=self.user)

+ 29 - 17
api/desecapi/tests/test_rrsets.py

@@ -40,8 +40,7 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
             self.client.get_rr_sets(self.my_domain.name, subname=''),
             self.client.get_rr_sets(self.my_domain.name, subname=''),
         ]:
         ]:
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(len(response.data), 2, response.data)
-            self.assertContainsRRSets(response.data, [dict(subname='', records=settings.DEFAULT_NS, type='NS')])
+            self.assertEqual(len(response.data), 1, response.data)
 
 
     def test_retrieve_other_rr_sets(self):
     def test_retrieve_other_rr_sets(self):
         self.assertStatus(self.client.get_rr_sets(self.other_domain.name), status.HTTP_404_NOT_FOUND)
         self.assertStatus(self.client.get_rr_sets(self.other_domain.name), status.HTTP_404_NOT_FOUND)
@@ -51,7 +50,7 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
     def test_retrieve_my_rr_sets_filter(self):
     def test_retrieve_my_rr_sets_filter(self):
         response = self.client.get_rr_sets(self.my_rr_set_domain.name)
         response = self.client.get_rr_sets(self.my_rr_set_domain.name)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(len(response.data), len(self._test_rr_sets()) + 1)  # Don't forget about the NS type RR set
+        self.assertEqual(len(response.data), len(self._test_rr_sets()))
 
 
         for subname in self.SUBNAMES:
         for subname in self.SUBNAMES:
             response = self.client.get_rr_sets(self.my_rr_set_domain.name, subname=subname)
             response = self.client.get_rr_sets(self.my_rr_set_domain.name, subname=subname)
@@ -62,25 +61,32 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
         for type_ in self.ALLOWED_TYPES:
         for type_ in self.ALLOWED_TYPES:
             response = self.client.get_rr_sets(self.my_rr_set_domain.name, type=type_)
             response = self.client.get_rr_sets(self.my_rr_set_domain.name, type=type_)
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
-            if type_ != 'NS':  # count does not match for NS, that's okay
-                self.assertRRSetsCount(response.data, [dict(type=type_)],
-                                       count=len(self._test_rr_sets(type_=type_)))
 
 
     def test_create_my_rr_sets(self):
     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 subname in [None, 'create-my-rr-sets', 'foo.create-my-rr-sets', 'bar.baz.foo.create-my-rr-sets']:
             for data in [
             for data in [
                 {'subname': subname, 'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'},
                 {'subname': subname, 'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'},
-                {'subname': subname, 'records': ['desec.io.'], 'ttl': 900, 'type': 'PTR'},
+                {'subname': '' if subname is None else subname, 'records': ['desec.io.'], 'ttl': 900, 'type': 'PTR'},
+                {'subname': '' if subname is None else subname, 'ttl': 50, 'type': 'TXT', 'records': ['"foo"']},
             ]:
             ]:
+                # Try POST with missing subname
+                if data['subname'] is None:
+                    data.pop('subname')
+
                 with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
                 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)
                     response = self.client.post_rr_set(domain_name=self.my_empty_domain.name, **data)
                     self.assertStatus(response, status.HTTP_201_CREATED)
                     self.assertStatus(response, status.HTTP_201_CREATED)
 
 
+                # Check for uniqueness on second attempt
+                response = self.client.post_rr_set(domain_name=self.my_empty_domain.name, **data)
+                self.assertContains(response, 'Another RRset with the same subdomain and type exists for this domain.',
+                                    status_code=status.HTTP_400_BAD_REQUEST)
+
                 response = self.client.get_rr_sets(self.my_empty_domain.name)
                 response = self.client.get_rr_sets(self.my_empty_domain.name)
                 self.assertStatus(response, status.HTTP_200_OK)
                 self.assertStatus(response, status.HTTP_200_OK)
                 self.assertRRSetsCount(response.data, [data])
                 self.assertRRSetsCount(response.data, [data])
 
 
-                response = self.client.get_rr_set(self.my_empty_domain.name, data['subname'], data['type'])
+                response = self.client.get_rr_set(self.my_empty_domain.name, data.get('subname', ''), data['type'])
                 self.assertStatus(response, status.HTTP_200_OK)
                 self.assertStatus(response, status.HTTP_200_OK)
                 self.assertRRSet(response.data, **data)
                 self.assertRRSet(response.data, **data)
 
 
@@ -111,11 +117,14 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
                 {'subname': subname, 'ttl': 60, 'type': 'A'},
                 {'subname': subname, 'ttl': 60, 'type': 'A'},
             ]:
             ]:
                 response = self.client.post_rr_set(self.my_empty_domain.name, **data)
                 response = self.client.post_rr_set(self.my_empty_domain.name, **data)
-                self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+                self.assertStatus(
+                    response,
+                    status.HTTP_400_BAD_REQUEST
+                )
 
 
                 response = self.client.get_rr_sets(self.my_empty_domain.name)
                 response = self.client.get_rr_sets(self.my_empty_domain.name)
                 self.assertStatus(response, status.HTTP_200_OK)
                 self.assertStatus(response, status.HTTP_200_OK)
-                self.assertRRSetsCount(response.data, [data], count=0)
+                self.assertRRSetsCount(response.data, [], count=0)
 
 
     def test_create_other_rr_sets(self):
     def test_create_other_rr_sets(self):
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
@@ -130,7 +139,7 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
 
 
         data['records'][0] = '3.2.2.1'
         data['records'][0] = '3.2.2.1'
         response = self.client.post_rr_set(self.my_empty_domain.name, **data)
         response = self.client.post_rr_set(self.my_empty_domain.name, **data)
-        self.assertStatus(response, status.HTTP_409_CONFLICT)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
     def test_create_my_rr_sets_upper_case(self):
     def test_create_my_rr_sets_upper_case(self):
         for subname in ['asdF', 'cAse', 'asdf.FOO', '--F', 'ALLCAPS']:
         for subname in ['asdF', 'cAse', 'asdf.FOO', '--F', 'ALLCAPS']:
@@ -183,20 +192,20 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
             for data in [
             for data in [
                 {'records': ['2.2.3.4'], 'ttl': 30},
                 {'records': ['2.2.3.4'], 'ttl': 30},
                 {'records': ['3.2.3.4']},
                 {'records': ['3.2.3.4']},
+                {'records': ['3.2.3.4', '9.8.8.7']},
                 {'ttl': 37},
                 {'ttl': 37},
             ]:
             ]:
                 with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_rr_set_domain.name)):
                 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)
                     response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname, 'A', **data)
                     self.assertStatus(response, status.HTTP_200_OK)
                     self.assertStatus(response, status.HTTP_200_OK)
-    
+
                 response = self.client.get_rr_set(self.my_rr_set_domain.name, subname, 'A')
                 response = self.client.get_rr_set(self.my_rr_set_domain.name, subname, 'A')
                 self.assertStatus(response, status.HTTP_200_OK)
                 self.assertStatus(response, status.HTTP_200_OK)
                 current_rr_set.update(data)
                 current_rr_set.update(data)
                 self.assertEqual(response.data['records'], current_rr_set['records'])
                 self.assertEqual(response.data['records'], current_rr_set['records'])
                 self.assertEqual(response.data['ttl'], current_rr_set['ttl'])
                 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)
+            response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname, 'A')
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
     def test_partially_update_other_rr_sets(self):
     def test_partially_update_other_rr_sets(self):
@@ -253,6 +262,9 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
                 response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A', records=[])
                 response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A', records=[])
                 self.assertStatus(response, status.HTTP_204_NO_CONTENT)
                 self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
 
+            response = self.client.patch_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A', records=[])
+            self.assertStatus(response, status.HTTP_204_NO_CONTENT)
+
             response = self.client.get_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A')
             response = self.client.get_rr_set(self.my_rr_set_domain.name, subname=subname, type_='A')
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
@@ -269,11 +281,11 @@ class AuthenticatedRRSetTestCase(AuthenticatedRRSetBaseTestCase):
         for subname in self.SUBNAMES:
         for subname in self.SUBNAMES:
             # Try PATCH empty
             # Try PATCH empty
             response = self.client.patch_rr_set(self.other_rr_set_domain.name, subname=subname, type_='A', records=[])
             response = self.client.patch_rr_set(self.other_rr_set_domain.name, subname=subname, type_='A', records=[])
-            self.assertStatus(response, status.HTTP_204_NO_CONTENT)
+            self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
             # Try DELETE
             # Try DELETE
             response = self.client.delete_rr_set(self.other_rr_set_domain.name, subname=subname, type_='A')
             response = self.client.delete_rr_set(self.other_rr_set_domain.name, subname=subname, type_='A')
-            self.assertStatus(response, status.HTTP_204_NO_CONTENT)
+            self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
 
             # Make sure it actually is still there
             # Make sure it actually is still there
             self.assertGreater(len(self.other_rr_set_domain.rrset_set.filter(subname=subname, type='A')), 0)
             self.assertGreater(len(self.other_rr_set_domain.rrset_set.filter(subname=subname, type='A')), 0)

+ 227 - 13
api/desecapi/tests/test_rrsets_bulk.py

@@ -13,12 +13,15 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
 
         cls.data = [
         cls.data = [
             {'subname': 'my-bulk', 'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'},
             {'subname': 'my-bulk', 'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'},
-            {'subname': 'my-bulk', 'records': ['desec.io.'], 'ttl': 60, 'type': 'PTR'},
+            {'subname': 'my-bulk', 'records': ['desec.io.', 'foobar.example.'], 'ttl': 60, 'type': 'PTR'},
         ]
         ]
 
 
         cls.data_no_records = copy.deepcopy(cls.data)
         cls.data_no_records = copy.deepcopy(cls.data)
         cls.data_no_records[1].pop('records')
         cls.data_no_records[1].pop('records')
 
 
+        cls.data_empty_records = copy.deepcopy(cls.data)
+        cls.data_empty_records[1]['records'] = []
+
         cls.data_no_subname = copy.deepcopy(cls.data)
         cls.data_no_subname = copy.deepcopy(cls.data)
         cls.data_no_subname[0].pop('subname')
         cls.data_no_subname[0].pop('subname')
 
 
@@ -31,6 +34,9 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
         cls.data_no_records_no_ttl = copy.deepcopy(cls.data_no_records)
         cls.data_no_records_no_ttl = copy.deepcopy(cls.data_no_records)
         cls.data_no_records_no_ttl[1].pop('ttl')
         cls.data_no_records_no_ttl[1].pop('ttl')
 
 
+        cls.data_no_subname_empty_records = copy.deepcopy(cls.data_no_subname)
+        cls.data_no_subname_empty_records[0]['records'] = []
+
         cls.bulk_domain = cls.create_domain(owner=cls.owner)
         cls.bulk_domain = cls.create_domain(owner=cls.owner)
         for data in cls.data:
         for data in cls.data:
             cls.create_rr_set(cls.bulk_domain, **data)
             cls.create_rr_set(cls.bulk_domain, **data)
@@ -44,32 +50,124 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertRRSetsCount(response.data, self.data)
         self.assertRRSetsCount(response.data, self.data)
 
 
+        # Check subname requirement on bulk endpoint (and uniqueness at the same time)
+        self.assertResponse(
+            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_subname),
+            status.HTTP_400_BAD_REQUEST,
+            [
+                {'subname': ['This field is required.']},
+                {'non_field_errors': ['Another RRset with the same subdomain and type exists for this domain.']}
+            ]
+        )
+
+    def test_bulk_post_rr_sets_empty_records(self):
+        expected_response_data = [copy.deepcopy(self.data_empty_records[0]), None]
+        expected_response_data[0]['domain'] = self.my_empty_domain.name
+        expected_response_data[0]['name'] = '%s.%s.' % (self.data_empty_records[0]['subname'],
+                                                        self.my_empty_domain.name)
+        self.assertResponse(
+            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_empty_records),
+            status.HTTP_400_BAD_REQUEST,
+            [
+                {},
+                {'records': ['This field must not be empty when using POST.']}
+            ]
+        )
+
+    def test_bulk_post_existing_rrsets(self):
+        self.assertResponse(
+            self.client.bulk_post_rr_sets(
+                domain_name=self.bulk_domain,
+                payload=self.data,
+            ),
+            status.HTTP_400_BAD_REQUEST,
+            2 * [{
+                'non_field_errors': ['Another RRset with the same subdomain and type exists for this domain.']
+            }]
+        )
+
+    def test_bulk_post_duplicates(self):
+        data = 2 * [self.data[0]] + [self.data[1]]
+        self.assertResponse(
+            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=data),
+            status.HTTP_400_BAD_REQUEST,
+            [
+                {'__all__': ['Same subname and type as in position(s) 1, but must be unique.']},
+                {'__all__': ['Same subname and type as in position(s) 0, but must be unique.']},
+                {},
+            ]
+        )
+
+        data = 2 * [self.data[0]] + [self.data[1]] + [self.data[0]]
+        self.assertResponse(
+            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=data),
+            status.HTTP_400_BAD_REQUEST,
+            [
+                {'__all__': ['Same subname and type as in position(s) 1, 3, but must be unique.']},
+                {'__all__': ['Same subname and type as in position(s) 0, 3, but must be unique.']},
+                {},
+                {'__all__': ['Same subname and type as in position(s) 0, 1, but must be unique.']},
+            ]
+        )
+
+    def test_bulk_post_missing_fields(self):
+        self.assertResponse(
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name,
+                payload=[
+                    {'subname': 'a.1', 'records': ['dead::beef'], 'ttl': 22},
+                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
+                    {'ttl': 40, 'type': 'TXT', 'records': ['"bar"']},
+                    {'subname': '', 'ttl': 40, 'type': 'TXT', 'records': ['"bar"']},
+                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
+                    {'subname': 'd.1', 'ttl': 50, 'type': 'AAAA'},
+                    {'subname': 'd.1', 'ttl': 50, 'type': 'SOA',
+                     'records': ['ns1.desec.io. peter.desec.io. 2018034419 10800 3600 604800 60']},
+                    {'subname': 'd.1', 'ttl': 50, 'type': 'OPT', 'records': ['9999']},
+                    {'subname': 'd.1', 'ttl': 50, 'type': 'TYPE099', 'records': ['v=spf1 mx -all']},
+                ]
+            ),
+            status.HTTP_400_BAD_REQUEST,
+            [
+                {'type': ['This field is required.']},
+                {'ttl': ['Ensure this value is greater than or equal to 1.']},
+                {'subname': ['This field is required.']},
+                {},
+                {'ttl': ['This field is required.']},
+                {'records': ['This field is required.']},
+                {'type': ['You cannot tinker with the SOA RRset.']},
+                {'type': ['You cannot tinker with the OPT RRset.']},
+                {'type': ['Generic type format is not supported.']},
+            ]
+        )
+
     def test_bulk_patch_fresh_rrsets_need_records(self):
     def test_bulk_patch_fresh_rrsets_need_records(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_records)
+        response = self.client.bulk_patch_rr_sets(self.my_empty_domain.name, payload=self.data_no_records)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data, [{}, {'records': ['This field is required for new RRsets.']}])
+        self.assertEqual(response.data, [{}, {'records': ['This field is required.']}])
 
 
-    def test_bulk_patch_fresh_rrsets_dont_need_subname(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
-            response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name,
-                                                      payload=self.data_no_subname)
+        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+            response = self.client.bulk_patch_rr_sets(self.my_empty_domain.name, payload=self.data_empty_records)
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertStatus(response, status.HTTP_200_OK)
 
 
-            # Check that RRsets have been created
-            response = self.client.get_rr_sets(self.my_empty_domain.name)
-            self.assertStatus(response, status.HTTP_200_OK)
-            self.assertRRSetsCount(response.data, self.data_no_subname)
+    def test_bulk_patch_fresh_rrsets_need_subname(self):
+        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_subname)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
     def test_bulk_patch_fresh_rrsets_need_ttl(self):
     def test_bulk_patch_fresh_rrsets_need_ttl(self):
         response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_ttl)
         response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_ttl)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data, [{'ttl': ['This field is required for new RRsets.']}, {}])
+        self.assertEqual(response.data, [{'ttl': ['This field is required.']}, {}])
 
 
     def test_bulk_patch_fresh_rrsets_need_type(self):
     def test_bulk_patch_fresh_rrsets_need_type(self):
         response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_type)
         response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data_no_type)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertEqual(response.data, [{}, {'type': ['This field is required.']}])
         self.assertEqual(response.data, [{}, {'type': ['This field is required.']}])
 
 
+    def test_bulk_patch_does_not_accept_single_objects(self):
+        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data[0])
+        self.assertContains(response, 'expected a list of RRsets.', status_code=status.HTTP_400_BAD_REQUEST)
+
     def test_bulk_patch_full_on_empty_domain(self):
     def test_bulk_patch_full_on_empty_domain(self):
         # Full patch always works
         # Full patch always works
         with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
         with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
@@ -103,8 +201,84 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertRRSetsCount(response.data, data_no_records)
         self.assertRRSetsCount(response.data, data_no_records)
 
 
+    def test_bulk_patch_does_not_need_ttl(self):
+        self.assertResponse(
+            self.client.bulk_patch_rr_sets(domain_name=self.bulk_domain.name, payload=self.data_no_ttl),
+            status.HTTP_200_OK,
+        )
+
+    def test_bulk_patch_delete_non_existing_rr_sets(self):
+        self.assertResponse(
+            self.client.bulk_patch_rr_sets(
+                domain_name=self.my_empty_domain.name,
+                payload=[
+                    {'subname': 'a', 'type': 'A', 'records': [], 'ttl': 22},
+                    {'subname': 'b', 'type': 'AAAA', 'records': []},
+                ]),
+            status.HTTP_200_OK,
+            [],
+        )
+
+    def test_bulk_patch_missing_invalid_fields_1(self):
+        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name,
+                payload=[
+                    {'subname': '', 'ttl': 50, 'type': 'TXT', 'records': ['"foo"']},
+                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA', 'ttl': 3},
+                    {'subname': 'd.1', 'ttl': 50, 'type': 'AAAA', 'records': ['::1', '::2']},
+                ]
+            )
+        self.assertResponse(
+            self.client.bulk_patch_rr_sets(
+                domain_name=self.my_empty_domain.name,
+                payload=[
+                    {'subname': 'a.1', 'records': ['dead::beef'], 'ttl': 22},
+                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
+                    {'ttl': 40, 'type': 'TXT', 'records': ['"bar"']},
+                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
+                    {'subname': 'd.1', 'ttl': 50, 'type': 'AAAA'},
+                ]),
+            status.HTTP_400_BAD_REQUEST,
+            [
+                {'type': ['This field is required.']},
+                {'ttl': ['Ensure this value is greater than or equal to 1.']},
+                {'subname': ['This field is required.']},
+                {},
+                {},
+            ]
+        )
+
+    def test_bulk_patch_missing_invalid_fields_2(self):
+        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name,
+                payload=[
+                    {'subname': '', 'ttl': 50, 'type': 'TXT', 'records': ['"foo"']}
+                ]
+            )
+        self.assertResponse(
+            self.client.bulk_patch_rr_sets(
+                domain_name=self.my_empty_domain.name,
+                payload=[
+                    {'subname': 'a.1', 'records': ['dead::beef'], 'ttl': 22},
+                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
+                    {'ttl': 40, 'type': 'TXT', 'records': ['"bar"']},
+                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
+                    {'subname': 'd.1', 'ttl': 50, 'type': 'AAAA'},
+                ]),
+            status.HTTP_400_BAD_REQUEST,
+            [
+                {'type': ['This field is required.']},
+                {'ttl': ['Ensure this value is greater than or equal to 1.']},
+                {'subname': ['This field is required.']},
+                {'ttl': ['This field is required.']},
+                {'records': ['This field is required.']},
+            ]
+        )
+
     def test_bulk_put_partial(self):
     def test_bulk_put_partial(self):
-        # Need TTL and type and records
+        # Need all fields
         for domain in [self.my_empty_domain, self.bulk_domain]:
         for domain in [self.my_empty_domain, self.bulk_domain]:
             response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_records)
             response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_records)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
@@ -120,10 +294,22 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
                                              {'ttl': ['This field is required.'],
                                              {'ttl': ['This field is required.'],
                                               'records': ['This field is required.']}])
                                               'records': ['This field is required.']}])
 
 
+            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_subname)
+            self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+            self.assertEqual(response.data, [{'subname': ['This field is required.']}, {}])
+
+            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_subname_empty_records)
+            self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+            self.assertEqual(response.data, [{'subname': ['This field is required.']}, {}])
+
             response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_type)
             response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_type)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertEqual(response.data, [{}, {'type': ['This field is required.']}])
             self.assertEqual(response.data, [{}, {'type': ['This field is required.']}])
 
 
+    def test_bulk_put_does_not_accept_single_objects(self):
+        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data[0])
+        self.assertContains(response, 'expected a list of RRsets.', status_code=status.HTTP_400_BAD_REQUEST)
+
     def test_bulk_put_full(self):
     def test_bulk_put_full(self):
         # Full PUT always works
         # Full PUT always works
         with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
         with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
@@ -139,6 +325,29 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
         response = self.client.bulk_put_rr_sets(domain_name=self.bulk_domain.name, payload=self.data)
         response = self.client.bulk_put_rr_sets(domain_name=self.bulk_domain.name, payload=self.data)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertStatus(response, status.HTTP_200_OK)
 
 
+    def test_bulk_put_invalid_records(self):
+        for records in [
+            'asfd',
+            ['1.1.1.1', '2.2.2.2', 123],
+            ['1.2.3.4', None],
+            [True, '1.1.1.1'],
+            dict(foobar='foobar', asdf='asdf'),
+        ]:
+            s = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=[
+                    {'subname': 'a.2', 'ttl': 50, 'type': 'MX', 'records': records}
+                ])
+            self.assertStatus(
+                s,
+                status.HTTP_400_BAD_REQUEST
+            )
+
+    def test_bulk_put_empty_records(self):
+        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.bulk_domain.name)):
+            self.assertStatus(
+                self.client.bulk_put_rr_sets(domain_name=self.bulk_domain.name, payload=self.data_empty_records),
+                status.HTTP_200_OK
+            )
+
     def test_bulk_duplicate_rrset(self):
     def test_bulk_duplicate_rrset(self):
         data = self.data + self.data
         data = self.data + self.data
         for bulk_request_rr_sets in [
         for bulk_request_rr_sets in [
@@ -148,3 +357,8 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
         ]:
         ]:
             response = bulk_request_rr_sets(domain_name=self.my_empty_domain.name, payload=data)
             response = bulk_request_rr_sets(domain_name=self.my_empty_domain.name, payload=data)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+
+    def test_bulk_patch_or_post_failure_with_single_rrset(self):
+        for method in [self.client.bulk_patch_rr_sets, self.client.bulk_put_rr_sets]:
+            response = method(domain_name=self.my_empty_domain.name, payload=self.data[0])
+            self.assertContains(response, 'Invalid input, expected a list of RRsets.', status_code=400)

+ 142 - 129
api/desecapi/views.py

@@ -3,6 +3,7 @@ import binascii
 import ipaddress
 import ipaddress
 import os
 import os
 import re
 import re
+from copy import deepcopy
 from datetime import timedelta
 from datetime import timedelta
 
 
 import django.core.exceptions
 import django.core.exceptions
@@ -10,7 +11,6 @@ import djoser.views
 import psl_dns
 import psl_dns
 from django.contrib.auth import user_logged_in, user_logged_out
 from django.contrib.auth import user_logged_in, user_logged_out
 from django.core.mail import EmailMessage
 from django.core.mail import EmailMessage
-from django.db import IntegrityError
 from django.db.models import Q
 from django.db.models import Q
 from django.http import Http404, HttpResponseRedirect
 from django.http import Http404, HttpResponseRedirect
 from django.shortcuts import render
 from django.shortcuts import render
@@ -24,22 +24,21 @@ from rest_framework import mixins
 from rest_framework import status
 from rest_framework import status
 from rest_framework.authentication import get_authorization_header
 from rest_framework.authentication import get_authorization_header
 from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError)
 from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError)
-from rest_framework.generics import get_object_or_404
+from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView, UpdateAPIView
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
-from rest_framework.settings import api_settings
 from rest_framework.views import APIView
 from rest_framework.views import APIView
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import GenericViewSet
-from rest_framework_bulk import ListBulkCreateUpdateAPIView
 
 
 import desecapi.authentication as auth
 import desecapi.authentication as auth
 from api import settings
 from api import settings
 from desecapi.emails import send_account_lock_email, send_token_email
 from desecapi.emails import send_account_lock_email, send_token_email
 from desecapi.forms import UnlockForm
 from desecapi.forms import UnlockForm
 from desecapi.models import Domain, User, RRset, Token
 from desecapi.models import Domain, User, RRset, Token
-from desecapi.permissions import IsOwner, IsUnlockedOrDyn, IsUnlocked, IsDomainOwner
-from desecapi.pdns import PdnsException
+from desecapi.pdns import PDNSException
+from desecapi.pdns_change_tracker import PDNSChangeTracker
+from desecapi.permissions import IsOwner, IsUnlocked, IsDomainOwner
 from desecapi.renderers import PlainTextRenderer
 from desecapi.renderers import PlainTextRenderer
 from desecapi.serializers import DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer
 from desecapi.serializers import DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer
 
 
@@ -47,6 +46,29 @@ patternDyn = re.compile(r'^[A-Za-z-][A-Za-z0-9_-]*\.dedyn\.io$')
 patternNonDyn = re.compile(r'^([A-Za-z0-9-][A-Za-z0-9_-]*\.)*[A-Za-z]+$')
 patternNonDyn = re.compile(r'^([A-Za-z0-9-][A-Za-z0-9_-]*\.)*[A-Za-z]+$')
 
 
 
 
+class IdempotentDestroy:
+
+    def destroy(self, request, *args, **kwargs):
+        try:
+            # noinspection PyUnresolvedReferences
+            super().destroy(request, *args, **kwargs)
+        except Http404:
+            pass
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class DomainView:
+
+    def initial(self, request, *args, **kwargs):
+        # noinspection PyUnresolvedReferences
+        super().initial(request, *args, **kwargs)
+        try:
+            # noinspection PyAttributeOutsideInit, PyUnresolvedReferences
+            self.domain = self.request.user.domains.get(name=self.kwargs['name'])
+        except Domain.DoesNotExist:
+            raise Http404
+
+
 class TokenCreateView(djoser.views.TokenCreateView):
 class TokenCreateView(djoser.views.TokenCreateView):
 
 
     def _action(self, serializer):
     def _action(self, serializer):
@@ -72,7 +94,8 @@ class TokenDestroyView(djoser.views.TokenDestroyView):
         return Response(status=status.HTTP_204_NO_CONTENT)
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
 
 
-class TokenViewSet(mixins.CreateModelMixin,
+class TokenViewSet(IdempotentDestroy,
+                   mixins.CreateModelMixin,
                    mixins.DestroyModelMixin,
                    mixins.DestroyModelMixin,
                    mixins.ListModelMixin,
                    mixins.ListModelMixin,
                    GenericViewSet):
                    GenericViewSet):
@@ -83,20 +106,13 @@ class TokenViewSet(mixins.CreateModelMixin,
     def get_queryset(self):
     def get_queryset(self):
         return self.request.user.auth_tokens.all()
         return self.request.user.auth_tokens.all()
 
 
-    def destroy(self, request, *args, **kwargs):
-        try:
-            super().destroy(request, *args, **kwargs)
-        except Http404:
-            pass
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
     def perform_create(self, serializer):
     def perform_create(self, serializer):
         serializer.save(user=self.request.user)
         serializer.save(user=self.request.user)
 
 
 
 
-class DomainList(generics.ListCreateAPIView):
+class DomainList(ListCreateAPIView):
     serializer_class = DomainSerializer
     serializer_class = DomainSerializer
-    permission_classes = (IsAuthenticated, IsOwner, IsUnlockedOrDyn,)
+    permission_classes = (IsAuthenticated, IsOwner,)
     psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER)
     psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER)
 
 
     def get_queryset(self):
     def get_queryset(self):
@@ -154,25 +170,33 @@ class DomainList(generics.ListCreateAPIView):
             raise ex
             raise ex
 
 
         try:
         try:
-            obj = serializer.save(owner=self.request.user)
-        except (IntegrityError, PdnsException) as e:
-            if isinstance(e, PdnsException) and not str(e).endswith(' already exists'):
+            with PDNSChangeTracker():
+                domain = serializer.save(owner=self.request.user)
+            parent_domain_name = domain.partition_name()[1]
+            if parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES:
+                parent_domain = Domain.objects.get(name=parent_domain_name)
+                # NOTE we need two change trackers here, as the first transaction must be committed to
+                # pdns in order to have keys available for the delegation
+                with PDNSChangeTracker():
+                    parent_domain.update_delegation(domain)
+        except PDNSException as e:
+            if not str(e).endswith(' already exists'):
                 raise e
                 raise e
             ex = ValidationError(detail={
             ex = ValidationError(detail={
                 "detail": "This domain name is unavailable.",
                 "detail": "This domain name is unavailable.",
                 "code": "domain-unavailable"}
                 "code": "domain-unavailable"}
             )
             )
-            ex.status_code = status.HTTP_409_CONFLICT
+            ex.status_code = status.HTTP_400_BAD_REQUEST
             raise ex
             raise ex
 
 
-        def send_dyn_dns_email(domain):
+        def send_dyn_dns_email():
             content_tmpl = get_template('emails/domain-dyndns/content.txt')
             content_tmpl = get_template('emails/domain-dyndns/content.txt')
             subject_tmpl = get_template('emails/domain-dyndns/subject.txt')
             subject_tmpl = get_template('emails/domain-dyndns/subject.txt')
             from_tmpl = get_template('emails/from.txt')
             from_tmpl = get_template('emails/from.txt')
             context = {
             context = {
-                'domain': domain.name,
+                'domain': domain_name,
                 'url': 'https://update.dedyn.io/',
                 'url': 'https://update.dedyn.io/',
-                'username': domain.name,
+                'username': domain_name,
                 'password': self.request.auth.key
                 'password': self.request.auth.key
             }
             }
             email = EmailMessage(subject_tmpl.render(context),
             email = EmailMessage(subject_tmpl.render(context),
@@ -181,21 +205,23 @@ class DomainList(generics.ListCreateAPIView):
                                  [self.request.user.email])
                                  [self.request.user.email])
             email.send()
             email.send()
 
 
-        if obj.name.endswith('.dedyn.io'):
-            send_dyn_dns_email(obj)
+        if domain.name.endswith('.dedyn.io'):
+            send_dyn_dns_email()
 
 
 
 
-class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
+class DomainDetail(IdempotentDestroy, RetrieveUpdateDestroyAPIView):
     serializer_class = DomainSerializer
     serializer_class = DomainSerializer
-    permission_classes = (IsAuthenticated, IsOwner, IsUnlocked,)
+    permission_classes = (IsAuthenticated, IsOwner,)
     lookup_field = 'name'
     lookup_field = 'name'
 
 
-    def delete(self, request, *args, **kwargs):
-        try:
-            super().delete(request, *args, **kwargs)
-        except Http404:
-            pass
-        return Response(status=status.HTTP_204_NO_CONTENT)
+    def perform_destroy(self, instance: Domain):
+        with PDNSChangeTracker():
+            instance.delete()
+        parent_domain_name = instance.partition_name()[1]
+        if parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES:
+            parent_domain = Domain.objects.get(name=parent_domain_name)
+            with PDNSChangeTracker():
+                parent_domain.update_delegation(instance)
 
 
     def get_queryset(self):
     def get_queryset(self):
         return Domain.objects.filter(owner=self.request.user.pk)
         return Domain.objects.filter(owner=self.request.user.pk)
@@ -207,79 +233,68 @@ class DomainDetail(generics.RetrieveUpdateDestroyAPIView):
             raise ValidationError(detail={"detail": e.message})
             raise ValidationError(detail={"detail": e.message})
 
 
 
 
-class RRsetDetail(generics.RetrieveUpdateDestroyAPIView):
+class RRsetDetail(IdempotentDestroy, DomainView, RetrieveUpdateDestroyAPIView):
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
     permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
     permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
 
 
-    def delete(self, request, *args, **kwargs):
-        try:
-            super().delete(request, *args, **kwargs)
-        except Http404:
-            pass
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
     def get_queryset(self):
     def get_queryset(self):
-        domain_name = self.kwargs['name']
+        return self.domain.rrset_set
 
 
-        try:
-            return self.request.user.domains.get(name=domain_name).rrset_set
-        except Domain.DoesNotExist:
-            raise Http404()
-
-    def get_object(self):
+    def get_object(self, raise_exception=True):
         queryset = self.filter_queryset(self.get_queryset())
         queryset = self.filter_queryset(self.get_queryset())
+        result = queryset.filter(type=self.kwargs['type'], subname=self.kwargs['subname'])
+        if result:
+            self.check_object_permissions(self.request, result[0])
+            return result[0]
+        else:
+            if raise_exception:
+                raise Http404
+            else:
+                return None
 
 
-        rrset_type = self.kwargs['type']
-
-        if rrset_type in RRset.RESTRICTED_TYPES:
-            raise PermissionDenied("You cannot tinker with the %s RRset." % rrset_type)
-
-        obj = get_object_or_404(queryset, type=rrset_type, subname=self.kwargs['subname'])
-
-        self.check_object_permissions(self.request, obj)
-
-        return obj
+    def get_serializer(self, *args, **kwargs):
+        kwargs['domain'] = self.domain
+        return super().get_serializer(*args, **kwargs)
 
 
     def update(self, request, *args, **kwargs):
     def update(self, request, *args, **kwargs):
-        if not isinstance(request.data, dict):
-            raise ValidationError({
-                api_settings.NON_FIELD_ERRORS_KEY: ['Invalid input, expected a JSON object.']
-            }, code='invalid')
-
-        records = request.data.get('records')
-        if records is not None and len(records) == 0:
-            return self.delete(request, *args, **kwargs)
-
-        # Attach URL parameters (self.kwargs) to the request body object (request.data),
+        # Attach URL parameters (self.kwargs) to the data object (copied from request.body),
         # the latter having preference with both are given.
         # the latter having preference with both are given.
+        data = deepcopy(request.data)
         for k in ('type', 'subname'):
         for k in ('type', 'subname'):
-            # This works because we exclusively use JSONParser which causes request.data to be
-            # a dict (and not an immutable QueryDict, as is the case for other parsers)
-            request.data[k] = request.data.pop(k, self.kwargs[k])
+            data[k] = request.data.pop(k, self.kwargs[k])
 
 
-        try:
-            return super().update(request, *args, **kwargs)
-        except django.core.exceptions.ValidationError as e:
-            ex = ValidationError(detail=e.message_dict)
-            ex.status_code = status.HTTP_409_CONFLICT
-            raise ex
+        partial = kwargs.pop('partial', False)
+        instance = self.get_object(raise_exception=False)
+        serializer = self.get_serializer(instance, data=data, partial=partial)
+        serializer.is_valid(raise_exception=True)
 
 
+        self.perform_update(serializer)
+        response = Response(serializer.data)
+        if response.data is None:
+            response.status_code = 204
+        return response
 
 
-class RRsetList(ListBulkCreateUpdateAPIView):
+    def perform_update(self, serializer):
+        with PDNSChangeTracker():
+            super().perform_update(serializer)
+
+    def perform_destroy(self, instance):
+        with PDNSChangeTracker():
+            super().perform_destroy(instance)
+
+
+class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
     serializer_class = RRsetSerializer
     serializer_class = RRsetSerializer
     permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
     permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
 
 
     def get_queryset(self):
     def get_queryset(self):
-        name = self.kwargs['name']
-        try:
-            rrsets = self.request.user.domains.get(name=name).rrset_set
-        except Domain.DoesNotExist:
-            raise Http404
+        rrsets = RRset.objects.filter(domain=self.domain)
 
 
         for filter_field in ('subname', 'type'):
         for filter_field in ('subname', 'type'):
             value = self.request.query_params.get(filter_field)
             value = self.request.query_params.get(filter_field)
 
 
             if value is not None:
             if value is not None:
+                # TODO consider moving this
                 if filter_field == 'type' and value in RRset.RESTRICTED_TYPES:
                 if filter_field == 'type' and value in RRset.RESTRICTED_TYPES:
                     raise PermissionDenied("You cannot tinker with the %s RRset." % value)
                     raise PermissionDenied("You cannot tinker with the %s RRset." % value)
 
 
@@ -287,42 +302,36 @@ class RRsetList(ListBulkCreateUpdateAPIView):
 
 
         return rrsets
         return rrsets
 
 
-    def create(self, request, *args, **kwargs):
-        try:
-            return super().create(request, *args, **kwargs)
-        except Domain.DoesNotExist:
-            raise Http404
-        except ValidationError as e:
-            if isinstance(e.detail, dict):
-                detail = e.detail.get('__all__')
-                if isinstance(detail, list) \
-                        and any(m.endswith(' already exists.') for m in detail):
-                    e.status_code = status.HTTP_409_CONFLICT
-            raise e
+    def get_object(self):
+        # For this view, the object we're operating on is the queryset that one can also GET. Serializing a queryset
+        # is fine as per https://www.django-rest-framework.org/api-guide/serializers/#serializing-multiple-objects.
+        # We skip checking object permissions here to avoid evaluating the queryset. The user can access all his RRsets
+        # anyways.
+        return self.filter_queryset(self.get_queryset())
+
+    def get_serializer(self, *args, **kwargs):
+        data = kwargs.get('data')
+        if data and 'many' not in kwargs:
+            if self.request.method == 'POST':
+                kwargs['many'] = isinstance(data, list)
+            elif self.request.method in ['PATCH', 'PUT']:
+                kwargs['many'] = True
+        return super().get_serializer(domain=self.domain, *args, **kwargs)
 
 
-    def perform_create(self, serializer):
-        # For new RRsets without a subname, set it empty. We don't use
-        # default='' in the serializer field definition so that during PUT, the
-        # subname value is retained if omitted.
-        if isinstance(self.request.data, list):
-            serializer._validated_data = [
-                {**{'subname': ''}, **data}
-                for data in serializer.validated_data
-            ]
+    def create(self, request, *args, **kwargs):
+        response = super().create(request, *args, **kwargs)
+        if not response.data:
+            return Response(status=status.HTTP_204_NO_CONTENT)
         else:
         else:
-            serializer._validated_data = {**{'subname': ''}, **serializer.validated_data}
-
-        # Associate RRset with proper domain
-        domain = self.request.user.domains.get(name=self.kwargs['name'])
-        serializer.save(domain=domain)
+            return response
 
 
-    def get(self, request, *args, **kwargs):
-        name = self.kwargs['name']
-
-        if not Domain.objects.filter(name=name, owner=self.request.user.pk):
-            raise Http404
+    def perform_create(self, serializer):
+        with PDNSChangeTracker():
+            serializer.save(domain=self.domain)
 
 
-        return super().get(request, *args, **kwargs)
+    def perform_update(self, serializer):
+        with PDNSChangeTracker():
+            serializer.save(domain=self.domain)
 
 
 
 
 class Root(APIView):
 class Root(APIView):
@@ -478,13 +487,23 @@ class DynDNS12Update(APIView):
         if domain is None:
         if domain is None:
             raise NotFound('nohost')
             raise NotFound('nohost')
 
 
-        datas = {'A': self._find_ip_v4(request), 'AAAA': self._find_ip_v6(request)}
-        rrsets = RRset.plain_to_rrsets(
-            [{'subname': '', 'type': type_, 'ttl': 60,
-              'contents': [ip] if ip is not None else []}
-             for type_, ip in datas.items()],
-            domain=domain)
-        domain.write_rrsets(rrsets)
+        ipv4 = self._find_ip_v4(request)
+        ipv6 = self._find_ip_v6(request)
+
+        data = [
+            {'type': 'A', 'subname': '', 'ttl': 60, 'records': [ipv4] if ipv4 else []},
+            {'type': 'AAAA', 'subname': '', 'ttl': 60, 'records': [ipv6] if ipv6 else []},
+        ]
+
+        instances = domain.rrset_set.filter(subname='', type__in=['A', 'AAAA']).all()
+        serializer = RRsetSerializer(instances, domain=domain, data=data, many=True, partial=True)
+        try:
+            serializer.is_valid(raise_exception=True)
+        except ValidationError as e:
+            raise e
+
+        with PDNSChangeTracker():
+            serializer.save(domain=domain)
 
 
         return Response('good', content_type='text/plain')
         return Response('good', content_type='text/plain')
 
 
@@ -572,13 +591,7 @@ def unlock(request, email):
         form = UnlockForm(request.POST)
         form = UnlockForm(request.POST)
         # check whether it's valid:
         # check whether it's valid:
         if form.is_valid():
         if form.is_valid():
-            try:
-                user = User.objects.get(email=email)
-                if user.locked:
-                    user.unlock()
-            except User.DoesNotExist:
-                # fail silently, so people can't probe registered addresses
-                pass
+            User.objects.filter(email=email).update(locked=None)
 
 
             return HttpResponseRedirect(reverse('v1:unlock/done', request=request))  # TODO remove dependency on v1
             return HttpResponseRedirect(reverse('v1:unlock/done', request=request))  # TODO remove dependency on v1
 
 

+ 42 - 20
test/e2e/spec/api_spec.js

@@ -404,13 +404,35 @@ describe("API v1", function () {
                     itShowsUpInPdnsAs('test.foobar', domain, 'AAAA', ['::1', 'bade::affe'], 60);
                     itShowsUpInPdnsAs('test.foobar', domain, 'AAAA', ['::1', 'bade::affe'], 60);
                 });
                 });
 
 
+                describe("cannot create RRsets with duplicate record content", function () {
+                    it("rejects exact duplicates", function () {
+                        return expect(chakram.post(
+                            '/domains/' + domain + '/rrsets/',
+                            {
+                                'subname': 'duplicate-contents', 'type': 'AAAA',
+                                'records': ['::1', '::1'], 'ttl': 60
+                            }
+                        )).to.have.status(422);
+                    });
+
+                    it("rejects semantic duplicates", function () {
+                        return expect(chakram.post(
+                            '/domains/' + domain + '/rrsets/',
+                            {
+                                'subname': 'duplicate-contents', 'type': 'AAAA',
+                                'records': ['::1', '::0001'], 'ttl': 60
+                            }
+                        )).to.have.status(422);
+                    });
+                });
+
                 describe("can bulk-post an AAAA and an MX record", function () {
                 describe("can bulk-post an AAAA and an MX record", function () {
                     before(function () {
                     before(function () {
                         var response = chakram.post(
                         var response = chakram.post(
                             '/domains/' + domain + '/rrsets/',
                             '/domains/' + domain + '/rrsets/',
                             [
                             [
                                 { 'subname': 'ipv6', 'type': 'AAAA', 'records': ['dead::beef'], 'ttl': 22 },
                                 { 'subname': 'ipv6', 'type': 'AAAA', 'records': ['dead::beef'], 'ttl': 22 },
-                                { /* implied: 'subname': '', */ 'type': 'MX', 'records': ['10 mail.example.com.', '20 mail.example.net.'], 'ttl': 33 }
+                                { 'subname': '', 'type': 'MX', 'records': ['10 mail.example.com.', '20 mail.example.net.'], 'ttl': 33 }
                             ]
                             ]
                         );
                         );
                         expect(response).to.have.status(201);
                         expect(response).to.have.status(201);
@@ -433,7 +455,7 @@ describe("API v1", function () {
                         // Set an RRset that we'll try to overwrite
                         // Set an RRset that we'll try to overwrite
                         var response = chakram.post(
                         var response = chakram.post(
                             '/domains/' + domain + '/rrsets/',
                             '/domains/' + domain + '/rrsets/',
-                            [{'ttl': 50, 'type': 'TXT', 'records': ['"foo"']}]
+                            {'ttl': 50, 'type': 'TXT', 'records': ['"foo"']}
                         );
                         );
                         expect(response).to.have.status(201);
                         expect(response).to.have.status(201);
 
 
@@ -454,7 +476,7 @@ describe("API v1", function () {
                         expect(response).to.have.json([
                         expect(response).to.have.json([
                             { type: [ 'This field is required.' ] },
                             { type: [ 'This field is required.' ] },
                             { ttl: [ 'Ensure this value is greater than or equal to 1.' ] },
                             { ttl: [ 'Ensure this value is greater than or equal to 1.' ] },
-                            {},
+                            { subname: [ 'This field is required.' ] },
                             { ttl: [ 'This field is required.' ] },
                             { ttl: [ 'This field is required.' ] },
                             { records: [ 'This field is required.' ] },
                             { records: [ 'This field is required.' ] },
                             { type: [ 'You cannot tinker with the SOA RRset.' ] },
                             { type: [ 'You cannot tinker with the SOA RRset.' ] },
@@ -528,8 +550,8 @@ describe("API v1", function () {
 
 
                         it("gives the right response", function () {
                         it("gives the right response", function () {
                             expect(response).to.have.json([
                             expect(response).to.have.json([
-                                { '__all__': [ 'R rset with this Domain, Subname and Type already exists.' ] },
-                                { '__all__': [ 'RRset repeated with same subname and type.' ] },
+                                {"__all__": ["Same subname and type as in position(s) 1, but must be unique."]},
+                                {"__all__": ["Same subname and type as in position(s) 0, but must be unique."]}
                             ]);
                             ]);
                             return chakram.wait();
                             return chakram.wait();
                         });
                         });
@@ -562,7 +584,7 @@ describe("API v1", function () {
 
 
                         it("gives the right response", function () {
                         it("gives the right response", function () {
                             return expect(response).to.have.json([
                             return expect(response).to.have.json([
-                                { '__all__': [ 'R rset with this Domain, Subname and Type already exists.' ] },
+                                {'records': ['This field must not be empty when using POST.']},
                             ]);
                             ]);
                         });
                         });
                     });
                     });
@@ -632,7 +654,7 @@ describe("API v1", function () {
                             '/domains/' + domain + '/rrsets/',
                             '/domains/' + domain + '/rrsets/',
                             [
                             [
                                 { 'subname': 'ipv6', 'type': 'AAAA', 'records': ['dead::beef'], 'ttl': 22 },
                                 { 'subname': 'ipv6', 'type': 'AAAA', 'records': ['dead::beef'], 'ttl': 22 },
-                                { /* implied: 'subname': '', */ 'type': 'MX', 'records': ['10 mail.example.com.', '20 mail.example.net.'], 'ttl': 33 }
+                                { 'subname': '', 'type': 'MX', 'records': ['10 mail.example.com.', '20 mail.example.net.'], 'ttl': 33 }
                             ]
                             ]
                         );
                         );
                         expect(response).to.have.status(200);
                         expect(response).to.have.status(200);
@@ -653,11 +675,11 @@ describe("API v1", function () {
                 describe("cannot bulk-put with missing or invalid fields", function () {
                 describe("cannot bulk-put with missing or invalid fields", function () {
                     before(function () {
                     before(function () {
                         // Set an RRset that we'll try to overwrite
                         // Set an RRset that we'll try to overwrite
-                        var response = chakram.put(
+                        var response = chakram.post(
                             '/domains/' + domain + '/rrsets/',
                             '/domains/' + domain + '/rrsets/',
-                            [{'ttl': 50, 'type': 'TXT', 'records': ['"foo"']}]
+                            {'ttl': 50, 'type': 'TXT', 'records': ['"foo"']}
                         );
                         );
-                        expect(response).to.have.status(200);
+                        expect(response).to.have.status(201);
 
 
                         var response = chakram.put(
                         var response = chakram.put(
                             '/domains/' + domain + '/rrsets/',
                             '/domains/' + domain + '/rrsets/',
@@ -673,7 +695,7 @@ describe("API v1", function () {
                         expect(response).to.have.json([
                         expect(response).to.have.json([
                             { type: [ 'This field is required.' ] },
                             { type: [ 'This field is required.' ] },
                             { ttl: [ 'Ensure this value is greater than or equal to 1.' ] },
                             { ttl: [ 'Ensure this value is greater than or equal to 1.' ] },
-                            {},
+                            { subname: [ 'This field is required.' ] },
                             { ttl: [ 'This field is required.' ] },
                             { ttl: [ 'This field is required.' ] },
                             { records: [ 'This field is required.' ] },
                             { records: [ 'This field is required.' ] },
                         ]);
                         ]);
@@ -759,8 +781,8 @@ describe("API v1", function () {
 
 
                         it("gives the right response", function () {
                         it("gives the right response", function () {
                             return expect(response).to.have.json([
                             return expect(response).to.have.json([
-                                { },
-                                { '__all__': [ 'RRset repeated with same subname and type.' ] },
+                                { '__all__': [ 'Same subname and type as in position(s) 1, but must be unique.' ] },
+                                { '__all__': [ 'Same subname and type as in position(s) 0, but must be unique.' ] },
                             ]);
                             ]);
                         });
                         });
 
 
@@ -861,7 +883,7 @@ describe("API v1", function () {
                             '/domains/' + domain + '/rrsets/',
                             '/domains/' + domain + '/rrsets/',
                             [
                             [
                                 { 'subname': 'ipv6', 'type': 'AAAA', 'records': ['dead::beef'], 'ttl': 22 },
                                 { 'subname': 'ipv6', 'type': 'AAAA', 'records': ['dead::beef'], 'ttl': 22 },
-                                { /* implied: 'subname': '', */ 'type': 'MX', 'records': ['10 mail.example.com.', '20 mail.example.net.'], 'ttl': 33 }
+                                { 'subname': '', 'type': 'MX', 'records': ['10 mail.example.com.', '20 mail.example.net.'], 'ttl': 33 }
                             ]
                             ]
                         );
                         );
                         expect(response).to.have.status(200);
                         expect(response).to.have.status(200);
@@ -884,7 +906,7 @@ describe("API v1", function () {
                         // Set an RRset that we'll try to overwrite
                         // Set an RRset that we'll try to overwrite
                         var response = chakram.post(
                         var response = chakram.post(
                             '/domains/' + domain + '/rrsets/',
                             '/domains/' + domain + '/rrsets/',
-                            [{'ttl': 50, 'type': 'TXT', 'records': ['"foo"']}]
+                            {'ttl': 50, 'type': 'TXT', 'records': ['"foo"']}
                         );
                         );
                         expect(response).to.have.status(201);
                         expect(response).to.have.status(201);
 
 
@@ -902,9 +924,9 @@ describe("API v1", function () {
                         expect(response).to.have.json([
                         expect(response).to.have.json([
                             { type: [ 'This field is required.' ] },
                             { type: [ 'This field is required.' ] },
                             { ttl: [ 'Ensure this value is greater than or equal to 1.' ] },
                             { ttl: [ 'Ensure this value is greater than or equal to 1.' ] },
-                            {},
-                            {},
-                            {},
+                            { subname: [ 'This field is required.' ] },
+                            { ttl: ['This field is required.']} ,
+                            { records: ['This field is required.']} ,
                         ]);
                         ]);
 
 
                         return chakram.wait();
                         return chakram.wait();
@@ -1000,8 +1022,8 @@ describe("API v1", function () {
 
 
                         it("gives the right response", function () {
                         it("gives the right response", function () {
                             return expect(response).to.have.json([
                             return expect(response).to.have.json([
-                                {},
-                                { '__all__': [ 'RRset repeated with same subname and type.' ] },
+                                { '__all__': [ 'Same subname and type as in position(s) 1, but must be unique.' ] },
+                                { '__all__': [ 'Same subname and type as in position(s) 0, but must be unique.' ] },
                             ]);
                             ]);
                         });
                         });