瀏覽代碼

refactor(api): introduce black formatting

Peter Thomassen 2 年之前
父節點
當前提交
671ca12b96
共有 100 個文件被更改,包括 5970 次插入2980 次删除
  1. 2 0
      README.md
  2. 2 2
      api/desecapi/apps.py
  3. 42 23
      api/desecapi/authentication.py
  4. 16 6
      api/desecapi/crypto.py
  5. 38 19
      api/desecapi/dns.py
  6. 21 11
      api/desecapi/exception_handlers.py
  7. 9 5
      api/desecapi/exceptions.py
  8. 14 10
      api/desecapi/mail_backends.py
  9. 32 18
      api/desecapi/management/commands/align-catalog-zone.py
  10. 69 29
      api/desecapi/management/commands/check-secondaries.py
  11. 46 32
      api/desecapi/management/commands/chores.py
  12. 27 17
      api/desecapi/management/commands/limit.py
  13. 44 22
      api/desecapi/management/commands/outreach-email.py
  14. 66 28
      api/desecapi/management/commands/scavenge-unused.py
  15. 28 17
      api/desecapi/management/commands/stop-abuse.py
  16. 17 14
      api/desecapi/management/commands/sync-from-pdns.py
  17. 30 19
      api/desecapi/management/commands/sync-to-pdns.py
  18. 42 13
      api/desecapi/metrics.py
  19. 371 100
      api/desecapi/migrations/0001_initial_squashed_again.py
  20. 3 3
      api/desecapi/migrations/0002_unmanaged_donations.py
  21. 3 3
      api/desecapi/migrations/0003_rr_content.py
  22. 7 4
      api/desecapi/migrations/0004_immortal_domains.py
  23. 15 4
      api/desecapi/migrations/0005_subname_validation.py
  24. 13 3
      api/desecapi/migrations/0006_cname_exclusivity.py
  25. 6 4
      api/desecapi/migrations/0007_email_citext.py
  26. 5 5
      api/desecapi/migrations/0008_token_perm_manage_tokens.py
  27. 8 4
      api/desecapi/migrations/0009_token_allowed_subnets.py
  28. 19 7
      api/desecapi/migrations/0010_token_expiration.py
  29. 11 7
      api/desecapi/migrations/0011_captcha_kind.py
  30. 15 4
      api/desecapi/migrations/0012_rrset_label_length.py
  31. 5 5
      api/desecapi/migrations/0013_user_needs_captcha.py
  32. 5 5
      api/desecapi/migrations/0014_replication.py
  33. 3 3
      api/desecapi/migrations/0015_rrset_touched_index.py
  34. 11 7
      api/desecapi/migrations/0016_default_auto_field.py
  35. 8 4
      api/desecapi/migrations/0017_alter_user_limit_domains.py
  36. 69 27
      api/desecapi/migrations/0018_tokendomainpolicy.py
  37. 6 4
      api/desecapi/migrations/0019_alter_user_is_active.py
  38. 5 5
      api/desecapi/migrations/0020_user_email_verified.py
  39. 15 5
      api/desecapi/migrations/0021_authenticatednoopuseraction.py
  40. 3 3
      api/desecapi/migrations/0022_user_outreach_preference.py
  41. 15 5
      api/desecapi/migrations/0023_authenticatedemailuseraction.py
  42. 16 6
      api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py
  43. 31 9
      api/desecapi/migrations/0025_alter_token_max_age_alter_token_max_unused_period.py
  44. 5 5
      api/desecapi/migrations/0026_remove_domain_replicated_and_more.py
  45. 6 6
      api/desecapi/migrations/0027_user_credentials_changed.py
  46. 19 11
      api/desecapi/models/authenticated_actions.py
  47. 16 12
      api/desecapi/models/base.py
  48. 10 10
      api/desecapi/models/captcha.py
  49. 116 54
      api/desecapi/models/domains.py
  50. 1 1
      api/desecapi/models/donation.py
  51. 91 52
      api/desecapi/models/records.py
  52. 65 31
      api/desecapi/models/tokens.py
  53. 44 30
      api/desecapi/models/users.py
  54. 12 5
      api/desecapi/pagination.py
  55. 116 55
      api/desecapi/pdns.py
  56. 177 95
      api/desecapi/pdns_change_tracker.py
  57. 18 8
      api/desecapi/permissions.py
  58. 8 6
      api/desecapi/renderers.py
  59. 87 51
      api/desecapi/serializers/authenticated_actions.py
  60. 12 6
      api/desecapi/serializers/captcha.py
  61. 83 37
      api/desecapi/serializers/domains.py
  62. 16 9
      api/desecapi/serializers/donation.py
  63. 168 93
      api/desecapi/serializers/records.py
  64. 32 13
      api/desecapi/serializers/tokens.py
  65. 33 15
      api/desecapi/serializers/users.py
  66. 3 1
      api/desecapi/signals.py
  67. 9 3
      api/desecapi/templatetags/action_extras.py
  68. 4 4
      api/desecapi/templatetags/sepa_extras.py
  69. 419 217
      api/desecapi/tests/base.py
  70. 125 51
      api/desecapi/tests/test_authentication.py
  71. 22 20
      api/desecapi/tests/test_captcha.py
  72. 46 16
      api/desecapi/tests/test_chores.py
  73. 21 11
      api/desecapi/tests/test_crypto.py
  74. 397 231
      api/desecapi/tests/test_domains.py
  75. 28 25
      api/desecapi/tests/test_donations.py
  76. 112 91
      api/desecapi/tests/test_dyndns12update.py
  77. 16 8
      api/desecapi/tests/test_limit.py
  78. 25 11
      api/desecapi/tests/test_mail_backends.py
  79. 245 125
      api/desecapi/tests/test_pdns_change_tracker.py
  80. 14 12
      api/desecapi/tests/test_replication.py
  81. 607 287
      api/desecapi/tests/test_rrsets.py
  82. 463 185
      api/desecapi/tests/test_rrsets_bulk.py
  83. 64 27
      api/desecapi/tests/test_stop_abuse.py
  84. 29 14
      api/desecapi/tests/test_throttling.py
  85. 156 61
      api/desecapi/tests/test_token_domain_policy.py
  86. 8 5
      api/desecapi/tests/test_token_policies.py
  87. 92 49
      api/desecapi/tests/test_tokens.py
  88. 303 179
      api/desecapi/tests/test_user_management.py
  89. 16 6
      api/desecapi/throttling.py
  90. 96 44
      api/desecapi/urls/version_1.py
  91. 1 1
      api/desecapi/urls/version_2.py
  92. 12 10
      api/desecapi/validators.py
  93. 92 45
      api/desecapi/views/authenticated_actions.py
  94. 13 11
      api/desecapi/views/base.py
  95. 1 1
      api/desecapi/views/captcha.py
  96. 24 16
      api/desecapi/views/domains.py
  97. 25 20
      api/desecapi/views/donation.py
  98. 70 35
      api/desecapi/views/dyndns.py
  99. 37 20
      api/desecapi/views/records.py
  100. 27 12
      api/desecapi/views/tokens.py

+ 2 - 0
README.md

@@ -383,6 +383,8 @@ While there are certainly many ways to get started hacking desec-stack, here is
 
     1. For PyCharm's Python Console, the environment variables of your `.env` file and `DJANGO_SETTINGS_MODULE=api.settings_quick_test` need to be configured in Settings › Build, Execution, Deployment › Console › Django Console. (Note that if you need to work with the database, you need to initialize it first by running all migrations; otherwise, the model tables will be missing from the database.)
 
+1. **Code quality.** We use [Black](https://pypi.org/project/black/) to ensure formatting consistency and minimal diffs. Before you commit Python code into the `api/` directory, please run `black api/desecapi/`.
+
 
 ## Debugging
 

+ 2 - 2
api/desecapi/apps.py

@@ -2,8 +2,8 @@ from django.apps import AppConfig as DjangoAppConfig
 
 
 class AppConfig(DjangoAppConfig):
-    default_auto_field = 'django.db.models.BigAutoField'
-    name = 'desecapi'
+    default_auto_field = "django.db.models.BigAutoField"
+    name = "desecapi"
 
     def ready(self):
         from desecapi import signals  # connect signals

+ 42 - 23
api/desecapi/authentication.py

@@ -9,10 +9,14 @@ from rest_framework.authentication import (
     BaseAuthentication,
     get_authorization_header,
     TokenAuthentication as RestFrameworkTokenAuthentication,
-    BasicAuthentication)
+    BasicAuthentication,
+)
 
 from desecapi.models import Domain, Token
-from desecapi.serializers import AuthenticatedBasicUserActionSerializer, EmailPasswordSerializer
+from desecapi.serializers import (
+    AuthenticatedBasicUserActionSerializer,
+    EmailPasswordSerializer,
+)
 
 
 class DynAuthenticationMixin:
@@ -20,7 +24,10 @@ class DynAuthenticationMixin:
         user, token = TokenAuthentication().authenticate_credentials(key)
         # Make sure username is not misleading
         try:
-            if username in ['', user.email] or Domain.objects.filter_qname(username.lower(), owner=user).exists():
+            if (
+                username in ["", user.email]
+                or Domain.objects.filter_qname(username.lower(), owner=user).exists()
+            ):
                 return user, token
         except ValueError:
             pass
@@ -34,7 +41,9 @@ class TokenAuthentication(RestFrameworkTokenAuthentication):
     # It thus exposes the failure reason when under timing attack.
     def authenticate(self, request):
         try:
-            user, token = super().authenticate(request)  # may raise exceptions.AuthenticationFailed if token is invalid
+            user, token = super().authenticate(
+                request
+            )  # may raise exceptions.AuthenticationFailed if token is invalid
         except TypeError:  # no token given
             return None  # unauthenticated
 
@@ -48,12 +57,12 @@ class TokenAuthentication(RestFrameworkTokenAuthentication):
         # In case the stack is run behind an application proxy, the address will be the proxy's address. Extracting the
         # real client address is currently not supported. For further information on this case, see
         # https://www.django-rest-framework.org/api-guide/throttling/#how-clients-are-identified
-        client_ip = ip_address(request.META.get('REMOTE_ADDR'))
+        client_ip = ip_address(request.META.get("REMOTE_ADDR"))
 
         # This can likely be done within Postgres with django-postgres-extensions (client_ip <<= ANY allowed_subnets).
         # However, the django-postgres-extensions package is unmaintained, and the GitHub repo has been archived.
         if not any(client_ip in subnet for subnet in token.allowed_subnets):
-            raise exceptions.AuthenticationFailed('Invalid token.')
+            raise exceptions.AuthenticationFailed("Invalid token.")
 
         return user, token
 
@@ -65,7 +74,7 @@ class TokenAuthentication(RestFrameworkTokenAuthentication):
             return None  # unauthenticated
 
         if not token.is_valid:
-            raise exceptions.AuthenticationFailed('Invalid token.')
+            raise exceptions.AuthenticationFailed("Invalid token.")
         token.last_used = timezone.now()
         token.save()
         return user, token
@@ -93,30 +102,33 @@ class BasicTokenAuthentication(BaseAuthentication, DynAuthenticationMixin):
     def authenticate(self, request):
         auth = get_authorization_header(request).split()
 
-        if not auth or auth[0].lower() != b'basic':
+        if not auth or auth[0].lower() != b"basic":
             return None
 
         if len(auth) == 1:
-            msg = 'Invalid basic auth token header. No credentials provided.'
+            msg = "Invalid basic auth token header. No credentials provided."
             raise exceptions.AuthenticationFailed(msg)
         elif len(auth) > 2:
-            msg = 'Invalid basic auth token header. Basic authentication string should not contain spaces.'
+            msg = "Invalid basic auth token header. Basic authentication string should not contain spaces."
             raise exceptions.AuthenticationFailed(msg)
 
         try:
-            username, key = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).split(':')
+            username, key = (
+                base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).split(":")
+            )
             return self.authenticate_credentials(username, key)
         except Exception:
             raise exceptions.AuthenticationFailed("badauth")
 
     def authenticate_header(self, request):
-        return 'Basic'
+        return "Basic"
 
 
 class URLParamAuthentication(BaseAuthentication, DynAuthenticationMixin):
     """
     Authentication against username/password as provided in URL parameters.
     """
+
     model = Token
 
     def authenticate(self, request):
@@ -125,15 +137,17 @@ class URLParamAuthentication(BaseAuthentication, DynAuthenticationMixin):
         using URL parameters.  Otherwise raises `AuthenticationFailed`.
         """
 
-        if 'username' not in request.query_params:
-            msg = 'No username URL parameter provided.'
+        if "username" not in request.query_params:
+            msg = "No username URL parameter provided."
             raise exceptions.AuthenticationFailed(msg)
-        if 'password' not in request.query_params:
-            msg = 'No password URL parameter provided.'
+        if "password" not in request.query_params:
+            msg = "No password URL parameter provided."
             raise exceptions.AuthenticationFailed(msg)
 
         try:
-            return self.authenticate_credentials(request.query_params['username'], request.query_params['password'])
+            return self.authenticate_credentials(
+                request.query_params["username"], request.query_params["password"]
+            )
         except Exception:
             raise exceptions.AuthenticationFailed("badauth")
 
@@ -144,7 +158,9 @@ class EmailPasswordPayloadAuthentication(BaseAuthentication):
     def authenticate(self, request):
         serializer = EmailPasswordSerializer(data=request.data)
         serializer.is_valid(raise_exception=True)
-        return self.authenticate_credentials(serializer.data['email'], serializer.data['password'], request)
+        return self.authenticate_credentials(
+            serializer.data["email"], serializer.data["password"], request
+        )
 
 
 class AuthenticatedBasicUserActionAuthentication(BaseAuthentication):
@@ -152,26 +168,29 @@ class AuthenticatedBasicUserActionAuthentication(BaseAuthentication):
     Authenticates a request based on whether the serializer determines the validity of the given verification code
     based on the view's 'code' kwarg and the view serializer's code validity period.
     """
+
     def authenticate(self, request):
-        view = request.parser_context['view']
+        view = request.parser_context["view"]
         return self.authenticate_credentials(view.get_serializer_context())
 
     def authenticate_credentials(self, context):
         serializer = AuthenticatedBasicUserActionSerializer(data={}, context=context)
         serializer.is_valid(raise_exception=True)
-        user = serializer.validated_data['user']
+        user = serializer.validated_data["user"]
 
-        email_verified = datetime.datetime.fromtimestamp(serializer.timestamp, datetime.timezone.utc)
+        email_verified = datetime.datetime.fromtimestamp(
+            serializer.timestamp, datetime.timezone.utc
+        )
         user.email_verified = max(user.email_verified or email_verified, email_verified)
         user.save()
 
         # When user.is_active is None, activation is pending.  We need to admit them to finish activation, so only
         # reject strictly False.  There are permissions to make sure that such accounts can't do anything else.
         if user.is_active == False:
-            raise exceptions.AuthenticationFailed('User inactive.')
+            raise exceptions.AuthenticationFailed("User inactive.")
         return user, None
 
 
 class TokenHasher(PBKDF2PasswordHasher):
-    algorithm = 'pbkdf2_sha256_iter1'
+    algorithm = "pbkdf2_sha256_iter1"
     iterations = 1

+ 16 - 6
api/desecapi/crypto.py

@@ -12,8 +12,18 @@ from desecapi import metrics
 
 def _derive_urlsafe_key(*, label, context):
     backend = default_backend()
-    kdf = KBKDFHMAC(algorithm=hashes.SHA256(), mode=Mode.CounterMode, length=32, rlen=4, llen=4,
-                    location=CounterLocation.BeforeFixed, label=label, context=context, fixed=None, backend=backend)
+    kdf = KBKDFHMAC(
+        algorithm=hashes.SHA256(),
+        mode=Mode.CounterMode,
+        length=32,
+        rlen=4,
+        llen=4,
+        location=CounterLocation.BeforeFixed,
+        label=label,
+        context=context,
+        fixed=None,
+        backend=backend,
+    )
     key = kdf.derive(settings.SECRET_KEY.encode())
     return urlsafe_b64encode(key)
 
@@ -26,18 +36,18 @@ def retrieve_key(*, label, context):
 
 
 def encrypt(data, *, context):
-    key = retrieve_key(label=b'crypt', context=context)
+    key = retrieve_key(label=b"crypt", context=context)
     value = Fernet(key=key).encrypt(data)
-    metrics.get('desecapi_key_encryption_success').labels(context).inc()
+    metrics.get("desecapi_key_encryption_success").labels(context).inc()
     return value
 
 
 def decrypt(token, *, context, ttl=None):
-    key = retrieve_key(label=b'crypt', context=context)
+    key = retrieve_key(label=b"crypt", context=context)
     f = Fernet(key=key)
     try:
         ret = f.extract_timestamp(token), f.decrypt(token, ttl=ttl)
-        metrics.get('desecapi_key_decryption_success').labels(context).inc()
+        metrics.get("desecapi_key_decryption_success").labels(context).inc()
         return ret
     except InvalidToken:
         raise ValueError

+ 38 - 19
api/desecapi/dns.py

@@ -18,20 +18,35 @@ def _strip_quotes_decorator(func):
 # WARNING: This is a global side-effect. It can't be done by extending a class, because dnspython hardcodes the use of
 # their dns.rdtypes.svcbbase.*Param classes in the global dns.rdtypes.svcbbase._class_for_key dictionary. We either have
 # to globally mess with that dict and insert our custom class, or we just mess with their classes directly.
-dns.rdtypes.svcbbase.ALPNParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.ALPNParam.to_text)
-dns.rdtypes.svcbbase.IPv4HintParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.IPv4HintParam.to_text)
-dns.rdtypes.svcbbase.IPv6HintParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.IPv6HintParam.to_text)
-dns.rdtypes.svcbbase.MandatoryParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.MandatoryParam.to_text)
-dns.rdtypes.svcbbase.PortParam.to_text = _strip_quotes_decorator(dns.rdtypes.svcbbase.PortParam.to_text)
+dns.rdtypes.svcbbase.ALPNParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.ALPNParam.to_text
+)
+dns.rdtypes.svcbbase.IPv4HintParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.IPv4HintParam.to_text
+)
+dns.rdtypes.svcbbase.IPv6HintParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.IPv6HintParam.to_text
+)
+dns.rdtypes.svcbbase.MandatoryParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.MandatoryParam.to_text
+)
+dns.rdtypes.svcbbase.PortParam.to_text = _strip_quotes_decorator(
+    dns.rdtypes.svcbbase.PortParam.to_text
+)
 
 
 @dns.immutable.immutable
 class CERT(dns.rdtypes.ANY.CERT.CERT):
     def to_text(self, origin=None, relativize=True, **kw):
-        certificate_type = str(self.certificate_type)  # upstream implementation calls _ctype_to_text
-        return "%s %d %s %s" % (certificate_type, self.key_tag,
-                                dns.dnssec.algorithm_to_text(self.algorithm),
-                                dns.rdata._base64ify(self.certificate, **kw))
+        certificate_type = str(
+            self.certificate_type
+        )  # upstream implementation calls _ctype_to_text
+        return "%s %d %s %s" % (
+            certificate_type,
+            self.key_tag,
+            dns.dnssec.algorithm_to_text(self.algorithm),
+            dns.rdata._base64ify(self.certificate, **kw),
+        )
 
 
 @dns.immutable.immutable
@@ -52,8 +67,9 @@ class LongQuotedTXT(dns.rdtypes.txtbase.TXTBase):
     def __init__(self, rdclass, rdtype, strings):
         # Same as in parent class, but with max_length=None. Note that we are calling __init__ from the grandparent.
         super(dns.rdtypes.txtbase.TXTBase, self).__init__(rdclass, rdtype)
-        self.strings = self._as_tuple(strings,
-                                      lambda x: self._as_bytes(x, True, max_length=None))
+        self.strings = self._as_tuple(
+            strings, lambda x: self._as_bytes(x, True, max_length=None)
+        )
 
     @classmethod
     def from_text(cls, rdclass, rdtype, tok, origin=None, relativize=True):
@@ -71,37 +87,40 @@ class LongQuotedTXT(dns.rdtypes.txtbase.TXTBase):
 
     def _to_wire(self, file, compress=None, origin=None, canonicalize=False):
         for long_s in self.strings:
-            for s in [long_s[i:i+255] for i in range(0, max(len(long_s), 1), 255)]:
+            for s in [long_s[i : i + 255] for i in range(0, max(len(long_s), 1), 255)]:
                 l = len(s)
                 assert l < 256
-                file.write(struct.pack('!B', l))
+                file.write(struct.pack("!B", l))
                 file.write(s)
 
 
 def _HostnameMixin(name_field, *, allow_root):
     # Taken from https://github.com/PowerDNS/pdns/blob/4646277d05f293777a3d2423a3b188ccdf42c6bc/pdns/dnsname.cc#L419
-    hostname_re = re.compile(r'^(([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)\.)+$')
+    hostname_re = re.compile(r"^(([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)\.)+$")
 
     class Mixin:
         def to_text(self, origin=None, relativize=True, **kw):
             name = getattr(self, name_field)
-            if not (allow_root and name == dns.name.root) and hostname_re.match(str(name)) is None:
-                raise ValueError(f'invalid {name_field}: {name}')
+            if (
+                not (allow_root and name == dns.name.root)
+                and hostname_re.match(str(name)) is None
+            ):
+                raise ValueError(f"invalid {name_field}: {name}")
             return super().to_text(origin, relativize, **kw)
 
     return Mixin
 
 
 @dns.immutable.immutable
-class MX(_HostnameMixin('exchange', allow_root=True), dns.rdtypes.ANY.MX.MX):
+class MX(_HostnameMixin("exchange", allow_root=True), dns.rdtypes.ANY.MX.MX):
     pass
 
 
 @dns.immutable.immutable
-class NS(_HostnameMixin('target', allow_root=False), dns.rdtypes.ANY.NS.NS):
+class NS(_HostnameMixin("target", allow_root=False), dns.rdtypes.ANY.NS.NS):
     pass
 
 
 @dns.immutable.immutable
-class SRV(_HostnameMixin('target', allow_root=True), dns.rdtypes.IN.SRV.SRV):
+class SRV(_HostnameMixin("target", allow_root=True), dns.rdtypes.IN.SRV.SRV):
     pass

+ 21 - 11
api/desecapi/exception_handlers.py

@@ -17,25 +17,35 @@ def exception_handler(exc, context):
     """
 
     def _log():
-        logger = logging.getLogger('django.request')
-        logger.error('{} Supplementary Information'.format(exc.__class__),
-                     exc_info=exc, stack_info=False)
+        logger = logging.getLogger("django.request")
+        logger.error(
+            "{} Supplementary Information".format(exc.__class__),
+            exc_info=exc,
+            stack_info=False,
+        )
 
     def _409():
-        return Response({'detail': f'Conflict: {exc}'}, status=status.HTTP_409_CONFLICT)
+        return Response({"detail": f"Conflict: {exc}"}, status=status.HTTP_409_CONFLICT)
 
     def _500():
-        return Response({'detail': "Internal Server Error. We're on it!"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+        return Response(
+            {"detail": "Internal Server Error. We're on it!"},
+            status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+        )
 
     def _503():
-        return Response({'detail': 'Please try again later.'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
+        return Response(
+            {"detail": "Please try again later."},
+            status=status.HTTP_503_SERVICE_UNAVAILABLE,
+        )
 
     # Catch DB OperationalError and log an extra error for additional context
     if (
-        isinstance(exc, OperationalError) and
-        isinstance(exc.args, (list, dict, tuple)) and
-        exc.args and
-        exc.args[0] in (
+        isinstance(exc, OperationalError)
+        and isinstance(exc.args, (list, dict, tuple))
+        and exc.args
+        and exc.args[0]
+        in (
             2002,  # Connection refused (Socket)
             2003,  # Connection refused (TCP)
             2005,  # Unresolved host name
@@ -45,7 +55,7 @@ def exception_handler(exc, context):
         )
     ):
         _log()
-        metrics.get('desecapi_database_unavailable').inc()
+        metrics.get("desecapi_database_unavailable").inc()
         return _503()
 
     handlers = {

+ 9 - 5
api/desecapi/exceptions.py

@@ -4,18 +4,22 @@ from rest_framework.exceptions import APIException
 
 class RequestEntityTooLarge(APIException):
     status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
-    default_detail = 'Payload too large.'
-    default_code = 'too_large'
+    default_detail = "Payload too large."
+    default_code = "too_large"
 
 
 class PDNSException(APIException):
     def __init__(self, response=None):
         self.response = response
-        detail = f'pdns response code: {response.status_code}, body: {response.text}' if response is not None else None
+        detail = (
+            f"pdns response code: {response.status_code}, body: {response.text}"
+            if response is not None
+            else None
+        )
         return super().__init__(detail)
 
 
 class ConcurrencyException(APIException):
     status_code = status.HTTP_429_TOO_MANY_REQUESTS
-    default_detail = 'Too many concurrent requests.'
-    default_code = 'concurrency_conflict'
+    default_detail = "Too many concurrent requests."
+    default_code = "concurrency_conflict"

+ 14 - 10
api/desecapi/mail_backends.py

@@ -13,8 +13,8 @@ logger = logging.getLogger(__name__)
 
 
 class MultiLaneEmailBackend(BaseEmailBackend):
-    config = {'ignore_result': True}
-    default_backend = 'django.core.mail.backends.smtp.EmailBackend'
+    config = {"ignore_result": True}
+    default_backend = "django.core.mail.backends.smtp.EmailBackend"
 
     def __init__(self, lane: str = None, fail_silently=False, **kwargs):
         lane = lane or next(iter(settings.TASK_CONFIG))
@@ -22,22 +22,26 @@ class MultiLaneEmailBackend(BaseEmailBackend):
         self.config.update(settings.TASK_CONFIG[lane])
         self.task_kwargs = kwargs.copy()
         # Make a copy to ensure we don't modify input dict when we set the 'lane'
-        self.task_kwargs['debug'] = self.task_kwargs.pop('debug', {}).copy()
-        self.task_kwargs['debug']['lane'] = lane
+        self.task_kwargs["debug"] = self.task_kwargs.pop("debug", {}).copy()
+        self.task_kwargs["debug"]["lane"] = lane
         super().__init__(fail_silently)
 
     def send_messages(self, email_messages):
         dict_messages = [email_to_dict(msg) for msg in email_messages]
-        TASKS[self.config['name']].delay(dict_messages, **self.task_kwargs)
+        TASKS[self.config["name"]].delay(dict_messages, **self.task_kwargs)
         return len(email_messages)
 
     @staticmethod
     def _run_task(messages, debug, **kwargs):
-        logger.warning('Sending queued email, details: %s', debug)
-        kwargs.setdefault('backend', kwargs.pop('backbackend', MultiLaneEmailBackend.default_backend))
+        logger.warning("Sending queued email, details: %s", debug)
+        kwargs.setdefault(
+            "backend", kwargs.pop("backbackend", MultiLaneEmailBackend.default_backend)
+        )
         with get_connection(**kwargs) as connection:
-            return connection.send_messages([dict_to_email(message) for message in messages])
-        
+            return connection.send_messages(
+                [dict_to_email(message) for message in messages]
+            )
+
     @property
     def task(self):
         return shared_task(**self.config)(self._run_task)
@@ -47,5 +51,5 @@ class MultiLaneEmailBackend(BaseEmailBackend):
 TASKS = {
     name: MultiLaneEmailBackend(lane=name, fail_silently=True).task
     for name in settings.TASK_CONFIG
-    if name.startswith('email_')
+    if name.startswith("email_")
 }

+ 32 - 18
api/desecapi/management/commands/align-catalog-zone.py

@@ -2,12 +2,20 @@ from django.conf import settings
 from django.core.management import BaseCommand
 
 from desecapi.exceptions import PDNSException
-from desecapi.pdns import _pdns_delete, _pdns_get, _pdns_post, NSLORD, NSMASTER, pdns_id, construct_catalog_rrset
+from desecapi.pdns import (
+    _pdns_delete,
+    _pdns_get,
+    _pdns_post,
+    NSLORD,
+    NSMASTER,
+    pdns_id,
+    construct_catalog_rrset,
+)
 
 
 class Command(BaseCommand):
     # https://tools.ietf.org/html/draft-muks-dnsop-dns-catalog-zones-04
-    help = 'Generate a catalog zone on nsmaster, based on zones known on nslord.'
+    help = "Generate a catalog zone on nsmaster, based on zones known on nslord."
 
     def add_arguments(self, parser):
         pass
@@ -16,13 +24,13 @@ class Command(BaseCommand):
         catalog_zone_id = pdns_id(settings.CATALOG_ZONE)
 
         # Fetch zones from NSLORD
-        response = _pdns_get(NSLORD, '/zones').json()
-        zones = {zone['name'] for zone in response}
+        response = _pdns_get(NSLORD, "/zones").json()
+        zones = {zone["name"] for zone in response}
 
         # Retrieve catalog zone serial (later reused for recreating the catalog zone, for allow for smooth rollover)
         try:
-            response = _pdns_get(NSMASTER, f'/zones/{catalog_zone_id}')
-            serial = response.json()['serial']
+            response = _pdns_get(NSMASTER, f"/zones/{catalog_zone_id}")
+            serial = response.json()["serial"]
         except PDNSException as e:
             if e.response.status_code == 404:
                 serial = None
@@ -31,28 +39,34 @@ class Command(BaseCommand):
 
         # Purge catalog zone if exists
         try:
-            _pdns_delete(NSMASTER, f'/zones/{catalog_zone_id}')
+            _pdns_delete(NSMASTER, f"/zones/{catalog_zone_id}")
         except PDNSException as e:
             if e.response.status_code != 404:
                 raise e
 
         # Create new catalog zone
         rrsets = [
-            construct_catalog_rrset(subname='', qtype='NS', rdata='invalid.'),  # as per the specification
-            construct_catalog_rrset(subname='version', qtype='TXT', rdata='"2"'),  # as per the specification
-            *(construct_catalog_rrset(zone=zone) for zone in zones)
+            construct_catalog_rrset(
+                subname="", qtype="NS", rdata="invalid."
+            ),  # as per the specification
+            construct_catalog_rrset(
+                subname="version", qtype="TXT", rdata='"2"'
+            ),  # as per the specification
+            *(construct_catalog_rrset(zone=zone) for zone in zones),
         ]
 
         data = {
-            'name': settings.CATALOG_ZONE + '.',
-            'kind': 'MASTER',
-            'dnssec': False,  # as per the specification
-            'nameservers': [],
-            'rrsets': rrsets,
+            "name": settings.CATALOG_ZONE + ".",
+            "kind": "MASTER",
+            "dnssec": False,  # as per the specification
+            "nameservers": [],
+            "rrsets": rrsets,
         }
 
         if serial is not None:
-            data['serial'] = serial + 1  # actually, pdns does increase this as well, but let's not rely on this
+            data["serial"] = (
+                serial + 1
+            )  # actually, pdns does increase this as well, but let's not rely on this
 
-        _pdns_post(NSMASTER, '/zones?rrsets=false', data)
-        print(f'Aligned catalog zone ({len(zones)} member zones).')
+        _pdns_post(NSMASTER, "/zones?rrsets=false", data)
+        print(f"Aligned catalog zone ({len(zones)} member zones).")

+ 69 - 29
api/desecapi/management/commands/check-secondaries.py

@@ -17,7 +17,7 @@ def query_serial(zone, server):
     Checks a zone's serial on a server.
     :return: serial if received; None if the server did not know; False on error
     """
-    query = dns.message.make_query(zone, 'SOA')
+    query = dns.message.make_query(zone, "SOA")
     try:
         response = dns.query.tcp(query, server, timeout=5)
     except dns.exception.Timeout:
@@ -30,18 +30,32 @@ def query_serial(zone, server):
 
 
 class Command(BaseCommand):
-    help = 'Check secondaries for consistency with nsmaster.'
+    help = "Check secondaries for consistency with nsmaster."
 
     def __init__(self, *args, **kwargs):
-        self.servers = {gethostbyname(server): server for server in settings.WATCHDOG_SECONDARIES}
+        self.servers = {
+            gethostbyname(server): server for server in settings.WATCHDOG_SECONDARIES
+        }
         super().__init__(*args, **kwargs)
 
     def add_arguments(self, parser):
-        parser.add_argument('domain-name', nargs='*',
-                            help='Domain name to check. If omitted, will check all recently published domains.')
-        parser.add_argument('--delay', type=int, default=120, help='Delay SOA checks to allow pending AXFRs to finish.')
-        parser.add_argument('--window', type=int, default=settings.WATCHDOG_WINDOW_SEC,
-                            help='Check domains that were published no longer than this many seconds ago.')
+        parser.add_argument(
+            "domain-name",
+            nargs="*",
+            help="Domain name to check. If omitted, will check all recently published domains.",
+        )
+        parser.add_argument(
+            "--delay",
+            type=int,
+            default=120,
+            help="Delay SOA checks to allow pending AXFRs to finish.",
+        )
+        parser.add_argument(
+            "--window",
+            type=int,
+            default=settings.WATCHDOG_WINDOW_SEC,
+            help="Check domains that were published no longer than this many seconds ago.",
+        )
 
     def find_outdated_servers(self, zone, local_serial):
         """
@@ -56,15 +70,29 @@ class Command(BaseCommand):
         return outdated
 
     def handle(self, *args, **options):
-        threshold = timezone.now() - timedelta(seconds=options['window'])
-        recent_domain_names = Domain.objects.filter(published__gt=threshold).values_list('name', flat=True)
-        serials = {zone: s for zone, s in pdns.get_serials().items() if zone.rstrip('.') in recent_domain_names}
-
-        if options['domain-name']:
-            serials = {zone: serial for zone, serial in serials.items() if zone.rstrip('.') in options['domain-name']}
-
-        print('Sleeping for {} seconds before checking {} domains ...'.format(options['delay'], len(serials)))
-        sleep(options['delay'])
+        threshold = timezone.now() - timedelta(seconds=options["window"])
+        recent_domain_names = Domain.objects.filter(
+            published__gt=threshold
+        ).values_list("name", flat=True)
+        serials = {
+            zone: s
+            for zone, s in pdns.get_serials().items()
+            if zone.rstrip(".") in recent_domain_names
+        }
+
+        if options["domain-name"]:
+            serials = {
+                zone: serial
+                for zone, serial in serials.items()
+                if zone.rstrip(".") in options["domain-name"]
+            }
+
+        print(
+            "Sleeping for {} seconds before checking {} domains ...".format(
+                options["delay"], len(serials)
+            )
+        )
+        sleep(options["delay"])
 
         outdated_zone_count = 0
         outdated_secondaries = set()
@@ -77,17 +105,25 @@ class Command(BaseCommand):
                 if serial is False:
                     timeouts.setdefault(server, [])
                     timeouts[server].append(zone)
-            outdated_serials = {k: serial for k, serial in outdated_serials.items() if serial is not False}
+            outdated_serials = {
+                k: serial
+                for k, serial in outdated_serials.items()
+                if serial is not False
+            }
 
             if outdated_serials:
                 outdated_secondaries.update(outdated_serials.keys())
-                output.append(f'{zone} ({local_serial}) is outdated on {outdated_serials}')
+                output.append(
+                    f"{zone} ({local_serial}) is outdated on {outdated_serials}"
+                )
                 print(output[-1])
                 outdated_zone_count += 1
             else:
-                print(f'{zone} ok')
+                print(f"{zone} ok")
 
-        output.append(f'Checked {len(serials)} domains, {outdated_zone_count} were outdated.')
+        output.append(
+            f"Checked {len(serials)} domains, {outdated_zone_count} were outdated."
+        )
         print(output[-1])
 
         self.report(outdated_secondaries, output, timeouts)
@@ -97,18 +133,22 @@ class Command(BaseCommand):
             return
 
         subject = f'{timeouts and "CRITICAL ALERT" or "ALERT"} {len(outdated_secondaries)} secondaries out of sync'
-        message = ''
+        message = ""
 
         if timeouts:
-            message += f'The following servers had timeouts:\n\n{timeouts}\n\n'
+            message += f"The following servers had timeouts:\n\n{timeouts}\n\n"
 
         if outdated_secondaries:
-            message += f'The following {len(outdated_secondaries)} secondaries are out of sync:\n'
+            message += f"The following {len(outdated_secondaries)} secondaries are out of sync:\n"
             for outdated_secondary in outdated_secondaries:
-                message += f'* {outdated_secondary}\n'
-            message += '\n'
+                message += f"* {outdated_secondary}\n"
+            message += "\n"
 
-        message += f'Current secondary IPs: {self.servers}\n'
-        message += '\n'.join(output)
+        message += f"Current secondary IPs: {self.servers}\n"
+        message += "\n".join(output)
 
-        mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+        mail_admins(
+            subject,
+            message,
+            connection=get_connection("django.core.mail.backends.smtp.EmailBackend"),
+        )

+ 46 - 32
api/desecapi/management/commands/chores.py

@@ -12,77 +12,85 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 class Command(BaseCommand):
-
     @staticmethod
     def delete_expired_captchas():
-        models.Captcha.objects.filter(created__lt=timezone.now() - settings.CAPTCHA_VALIDITY_PERIOD).delete()
+        models.Captcha.objects.filter(
+            created__lt=timezone.now() - settings.CAPTCHA_VALIDITY_PERIOD
+        ).delete()
 
     @staticmethod
     def delete_never_activated_users():
         # delete inactive users whose activation link expired and who never logged in
         # (this will not delete users who have used their account and were later disabled)
-        models.User.objects.filter(is_active__isnull=True, last_login__isnull=True,
-                            created__lt=timezone.now() - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE).delete()
+        models.User.objects.filter(
+            is_active__isnull=True,
+            last_login__isnull=True,
+            created__lt=timezone.now()
+            - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE,
+        ).delete()
 
     @staticmethod
     def update_healthcheck_timestamp():
-        name = 'internal-timestamp.desec.test'
+        name = "internal-timestamp.desec.test"
         try:
             domain = models.Domain.objects.get(name=name)
         except models.Domain.DoesNotExist:
             # Fail silently. If external alerting is configured, it will catch the problem; otherwise, we don't need it.
-            print(f'{name} zone is not configured; skipping TXT record update')
+            print(f"{name} zone is not configured; skipping TXT record update")
             return
 
-        instances = domain.rrset_set.filter(subname='', type='TXT').all()
+        instances = domain.rrset_set.filter(subname="", type="TXT").all()
         timestamp = int(time.time())
         content = f'"{timestamp}"'
-        data = [{
-            'subname': '',
-            'type': 'TXT',
-            'ttl': '3600',
-            'records': [content]
-        }]
-        context = {'domain': domain}
-        serializer = serializers.RRsetSerializer(instances, data=data, many=True, partial=True, context=context)
+        data = [{"subname": "", "type": "TXT", "ttl": "3600", "records": [content]}]
+        context = {"domain": domain}
+        serializer = serializers.RRsetSerializer(
+            instances, data=data, many=True, partial=True, context=context
+        )
         serializer.is_valid(raise_exception=True)
         with PDNSChangeTracker():
             serializer.save()
-        print(f'TXT {name} updated to {content}')
+        print(f"TXT {name} updated to {content}")
 
     @staticmethod
     def alerting_healthcheck():
-        name = 'external-timestamp.desec.test'
+        name = "external-timestamp.desec.test"
         try:
             models.Domain.objects.get(name=name)
         except models.Domain.DoesNotExist:
-            print(f'{name} zone is not configured; skipping alerting health check')
+            print(f"{name} zone is not configured; skipping alerting health check")
             return
 
         timestamps = []
         qname = dns.name.from_text(name)
         query = dns.message.make_query(qname, dns.rdatatype.TXT)
-        server = gethostbyname('ns1.desec.io')
+        server = gethostbyname("ns1.desec.io")
         response = None
         try:
             response = dns.query.tcp(query, server, timeout=5)
-            for content in response.find_rrset(dns.message.ANSWER, qname, dns.rdataclass.IN, dns.rdatatype.TXT):
+            for content in response.find_rrset(
+                dns.message.ANSWER, qname, dns.rdataclass.IN, dns.rdatatype.TXT
+            ):
                 timestamps.append(str(content)[1:-1])
         except Exception:
             pass
 
         now = time.time()
         if any(now - 600 <= int(timestamp) <= now for timestamp in timestamps):
-            print(f'TXT {name} up to date.')
+            print(f"TXT {name} up to date.")
             return
 
-        timestamps = ', '.join(timestamps)
-        print(f'TXT {name} out of date! Timestamps: {timestamps}')
-        subject = 'ALERT Alerting system down?'
-        message = f'TXT query for {name} on {server} gave the following response:\n'
-        message += f'{str(response)}\n\n'
-        message += f'Extracted timestamps in TXT RRset:\n{timestamps}'
-        mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+        timestamps = ", ".join(timestamps)
+        print(f"TXT {name} out of date! Timestamps: {timestamps}")
+        subject = "ALERT Alerting system down?"
+        message = f"TXT query for {name} on {server} gave the following response:\n"
+        message += f"{str(response)}\n\n"
+        message += f"Extracted timestamps in TXT RRset:\n{timestamps}"
+        mail_admins(
+            subject,
+            message,
+            connection=get_connection("django.core.mail.backends.smtp.EmailBackend"),
+        )
 
     def handle(self, *args, **kwargs):
         try:
@@ -91,7 +99,13 @@ class Command(BaseCommand):
             self.delete_expired_captchas()
             self.delete_never_activated_users()
         except Exception as e:
-            subject = 'chores Exception!'
-            message = f'{type(e)}\n\n{str(e)}'
-            print(f'Chores exception: {type(e)}, {str(e)}')
-            mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+            subject = "chores Exception!"
+            message = f"{type(e)}\n\n{str(e)}"
+            print(f"Chores exception: {type(e)}, {str(e)}")
+            mail_admins(
+                subject,
+                message,
+                connection=get_connection(
+                    "django.core.mail.backends.smtp.EmailBackend"
+                ),
+            )

+ 27 - 17
api/desecapi/management/commands/limit.py

@@ -7,32 +7,42 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 class Command(BaseCommand):
-    help = 'Sets/updates limits for users and domains.'
+    help = "Sets/updates limits for users and domains."
 
     def add_arguments(self, parser):
-        parser.add_argument('kind',
-                            help='Identifies which limit should be updated. Possible values: domains, ttl')
-        parser.add_argument('id',
-                            help='Identifies the entity to be updated. Users are identified by email address; '
-                                 'domains by their name.')
-        parser.add_argument('new_limit', help='New value for the limit.')
+        parser.add_argument(
+            "kind",
+            help="Identifies which limit should be updated. Possible values: domains, ttl",
+        )
+        parser.add_argument(
+            "id",
+            help="Identifies the entity to be updated. Users are identified by email address; "
+            "domains by their name.",
+        )
+        parser.add_argument("new_limit", help="New value for the limit.")
 
     def handle(self, *args, **options):
-        if options['kind'] == 'domains':
+        if options["kind"] == "domains":
             try:
-                user = User.objects.get(email=options['id'])
+                user = User.objects.get(email=options["id"])
             except User.DoesNotExist:
-                raise CommandError(f'User with email address "{options["id"]}" could not be found.')
-            user.limit_domains = options['new_limit']
+                raise CommandError(
+                    f'User with email address "{options["id"]}" could not be found.'
+                )
+            user.limit_domains = options["new_limit"]
             user.save()
-            print(f'Updated {user.email}: set max number of domains to {user.limit_domains}.')
-        elif options['kind'] == 'ttl':
+            print(
+                f"Updated {user.email}: set max number of domains to {user.limit_domains}."
+            )
+        elif options["kind"] == "ttl":
             try:
-                domain = Domain.objects.get(name=options['id'])
+                domain = Domain.objects.get(name=options["id"])
             except Domain.DoesNotExist:
-                raise CommandError(f'Domain with name "{options["id"]}" could not be found.')
-            domain.minimum_ttl = options['new_limit']
+                raise CommandError(
+                    f'Domain with name "{options["id"]}" could not be found.'
+                )
+            domain.minimum_ttl = options["new_limit"]
             domain.save()
-            print(f'Updated {domain.name}: set minimum TTL to {domain.minimum_ttl}.')
+            print(f"Updated {domain.name}: set minimum TTL to {domain.minimum_ttl}.")
         else:
             raise CommandError(f'Unknown limit "{options["kind"]}" specified.')

+ 44 - 22
api/desecapi/management/commands/outreach-email.py

@@ -19,44 +19,66 @@ def _get_default_template_backend():
 
 
 class Command(BaseCommand):
-    help = 'Reach out to users with an email. Takes email template on stdin.'
+    help = "Reach out to users with an email. Takes email template on stdin."
 
     def add_arguments(self, parser):
-        parser.add_argument('email', nargs='*', help='User(s) to contact, identified by their email addresses. '
-                            'Defaults to everyone with outreach_preference = True, excluding inactive users.')
-        parser.add_argument('--contentfile', nargs='?', type=argparse.FileType('r'), default=sys.stdin,
-                            help='File to take email content from. Defaults to stdin.')
-        parser.add_argument('--reason', nargs='?', default='change-outreach-preference',
-                            help='Kind of message to send. Choose from reasons given in serializers.py. Defaults to '
-                                 'newsletter with unsubscribe link (reason: change-outreach-preference).')
-        parser.add_argument('--subject', nargs='?', default=None, help='Subject, default according to "reason".')
+        parser.add_argument(
+            "email",
+            nargs="*",
+            help="User(s) to contact, identified by their email addresses. "
+            "Defaults to everyone with outreach_preference = True, excluding inactive users.",
+        )
+        parser.add_argument(
+            "--contentfile",
+            nargs="?",
+            type=argparse.FileType("r"),
+            default=sys.stdin,
+            help="File to take email content from. Defaults to stdin.",
+        )
+        parser.add_argument(
+            "--reason",
+            nargs="?",
+            default="change-outreach-preference",
+            help="Kind of message to send. Choose from reasons given in serializers.py. Defaults to "
+            "newsletter with unsubscribe link (reason: change-outreach-preference).",
+        )
+        parser.add_argument(
+            "--subject",
+            nargs="?",
+            default=None,
+            help='Subject, default according to "reason".',
+        )
 
     def handle(self, *args, **options):
-        reason = options['reason']
-        path = reverse(f'v1:confirm-{reason}', args=['code'])
+        reason = options["reason"]
+        path = reverse(f"v1:confirm-{reason}", args=["code"])
         serializer_class = resolve(path).func.cls.serializer_class
 
-        content = options['contentfile'].read().strip()
-        if not content and options['contentfile'].name != '/dev/null':
-            raise RuntimeError('Empty content only allowed from /dev/null')
+        content = options["contentfile"].read().strip()
+        if not content and options["contentfile"].name != "/dev/null":
+            raise RuntimeError("Empty content only allowed from /dev/null")
 
         try:
-            subject = '[deSEC] ' + options['subject']
+            subject = "[deSEC] " + options["subject"]
         except TypeError:
             subject = None
 
-        base_file = f'emails/{reason}/content.txt'
-        template_code = ('{%% extends "%s" %%}' % base_file)
+        base_file = f"emails/{reason}/content.txt"
+        template_code = '{%% extends "%s" %%}' % base_file
         if content:
-            template_code += '{% block content %}' + content + '{% endblock %}'
+            template_code += "{% block content %}" + content + "{% endblock %}"
         template = _get_default_template_backend().from_string(template_code)
 
-        if options['email']:
-            users = User.objects.filter(email__in=options['email'])
+        if options["email"]:
+            users = User.objects.filter(email__in=options["email"])
         elif content:
-            users = User.objects.exclude(is_active=False).filter(outreach_preference=True)
+            users = User.objects.exclude(is_active=False).filter(
+                outreach_preference=True
+            )
         else:
-            raise RuntimeError('To send default content, specify recipients explicitly.')
+            raise RuntimeError(
+                "To send default content, specify recipients explicitly."
+            )
 
         for user in users:
             action = serializer_class.Meta.model(user=user)

+ 66 - 28
api/desecapi/management/commands/scavenge-unused.py

@@ -17,40 +17,57 @@ notice_days_warn = 7
 
 
 class Command(BaseCommand):
-    base_queryset = models.Domain.objects\
-        .exclude(renewal_state=models.Domain.RenewalState.IMMORTAL).filter(owner__is_active=True)
-    _rrsets_outer_queryset = models.RRset.objects.filter(domain=OuterRef('pk')).values('domain')  # values() is GROUP BY
-    _max_touched = Subquery(_rrsets_outer_queryset.annotate(max_touched=Max('touched')).values('max_touched'))
+    base_queryset = models.Domain.objects.exclude(
+        renewal_state=models.Domain.RenewalState.IMMORTAL
+    ).filter(owner__is_active=True)
+    _rrsets_outer_queryset = models.RRset.objects.filter(domain=OuterRef("pk")).values(
+        "domain"
+    )  # values() is GROUP BY
+    _max_touched = Subquery(
+        _rrsets_outer_queryset.annotate(max_touched=Max("touched")).values(
+            "max_touched"
+        )
+    )
 
     @classmethod
     def renew_touched_domains(cls):
         recently_active_domains = cls.base_queryset.annotate(
-            last_active=Greatest(cls._max_touched, 'published')
+            last_active=Greatest(cls._max_touched, "published")
         ).filter(
             last_active__date__gte=timezone.localdate() - datetime.timedelta(days=183),
-            renewal_changed__lt=F('last_active'),
+            renewal_changed__lt=F("last_active"),
         )
 
-        print('Renewing domains:', *recently_active_domains.values_list('name', flat=True))
-        recently_active_domains.update(renewal_state=models.Domain.RenewalState.FRESH, renewal_changed=F('last_active'))
+        print(
+            "Renewing domains:", *recently_active_domains.values_list("name", flat=True)
+        )
+        recently_active_domains.update(
+            renewal_state=models.Domain.RenewalState.FRESH,
+            renewal_changed=F("last_active"),
+        )
 
     @classmethod
     def warn_domain_deletion(cls, renewal_state, notice_days, inactive_days):
         # We act when `renewal_changed` is at least this date (or older)
-        inactive_threshold = timezone.localdate() - datetime.timedelta(days=inactive_days)
+        inactive_threshold = timezone.localdate() - datetime.timedelta(
+            days=inactive_days
+        )
         # Filter candidates which have the state of interest, at least since the calculated date
-        expiry_candidates = cls.base_queryset.filter(renewal_state=renewal_state,
-                                                     renewal_changed__date__lte=inactive_threshold)
+        expiry_candidates = cls.base_queryset.filter(
+            renewal_state=renewal_state, renewal_changed__date__lte=inactive_threshold
+        )
 
         # Group domains by user, so that we can send one message per user
         domain_user_map = {}
-        for domain in expiry_candidates.order_by('name'):
+        for domain in expiry_candidates.order_by("name"):
             if domain.owner not in domain_user_map:
                 domain_user_map[domain.owner] = []
             domain_user_map[domain.owner].append(domain)
 
         # Prepare and send emails, and keep renewal status in sync
-        context = {'deletion_date': timezone.localdate() + datetime.timedelta(days=notice_days)}
+        context = {
+            "deletion_date": timezone.localdate() + datetime.timedelta(days=notice_days)
+        }
         for user, domains in domain_user_map.items():
             with transaction.atomic():
                 # Update renewal status of the user's affected domains, but don't commit before sending the email
@@ -58,17 +75,27 @@ class Command(BaseCommand):
                 for domain in domains:
                     domain.renewal_state += 1
                     domain.renewal_changed = timezone.now()
-                    domain.save(update_fields=['renewal_state', 'renewal_changed'])
-                    actions.append(models.AuthenticatedRenewDomainBasicUserAction(user=user, domain=domain))
-                serializers.AuthenticatedRenewDomainBasicUserActionSerializer(actions, many=True, context=context).save()
+                    domain.save(update_fields=["renewal_state", "renewal_changed"])
+                    actions.append(
+                        models.AuthenticatedRenewDomainBasicUserAction(
+                            user=user, domain=domain
+                        )
+                    )
+                serializers.AuthenticatedRenewDomainBasicUserActionSerializer(
+                    actions, many=True, context=context
+                ).save()
 
     @classmethod
     def delete_domains(cls, inactive_days):
-        expired_domains = cls.base_queryset.filter(renewal_state=models.Domain.RenewalState.WARNED).annotate(
-            last_active=Greatest(cls._max_touched, 'published')
-        ).filter(
-            renewal_changed__date__lte=timezone.localdate() - datetime.timedelta(days=notice_days_warn),
-            last_active__date__lte=timezone.localdate() - datetime.timedelta(days=inactive_days),
+        expired_domains = (
+            cls.base_queryset.filter(renewal_state=models.Domain.RenewalState.WARNED)
+            .annotate(last_active=Greatest(cls._max_touched, "published"))
+            .filter(
+                renewal_changed__date__lte=timezone.localdate()
+                - datetime.timedelta(days=notice_days_warn),
+                last_active__date__lte=timezone.localdate()
+                - datetime.timedelta(days=inactive_days),
+            )
         )
 
         for domain in expired_domains:
@@ -88,17 +115,28 @@ class Command(BaseCommand):
 
             # Announce domain deletion in `notice_days_notice` days if not yet notified (FRESH) and inactive for
             # `inactive_days` days. Updates status from FRESH to NOTIFIED.
-            self.warn_domain_deletion(models.Domain.RenewalState.FRESH, notice_days_notify, fresh_days)
+            self.warn_domain_deletion(
+                models.Domain.RenewalState.FRESH, notice_days_notify, fresh_days
+            )
 
             # After `notice_days_notify - notice_days_warn` more days, warn again if the status has not changed
             # Updates status from NOTIFIED to WARNED.
-            self.warn_domain_deletion(models.Domain.RenewalState.NOTIFIED, notice_days_warn,
-                                      notice_days_notify - notice_days_warn)
+            self.warn_domain_deletion(
+                models.Domain.RenewalState.NOTIFIED,
+                notice_days_warn,
+                notice_days_notify - notice_days_warn,
+            )
 
             # Finally, delete domains inactive for `inactive_days + notice_days_notify` days if status has not changed
             self.delete_domains(fresh_days + notice_days_notify)
         except Exception as e:
-            subject = 'Renewal Exception!'
-            message = f'{type(e)}\n\n{str(e)}'
-            print(f'Chores exception: {type(e)}, {str(e)}')
-            mail_admins(subject, message, connection=get_connection('django.core.mail.backends.smtp.EmailBackend'))
+            subject = "Renewal Exception!"
+            message = f"{type(e)}\n\n{str(e)}"
+            print(f"Chores exception: {type(e)}, {str(e)}")
+            mail_admins(
+                subject,
+                message,
+                connection=get_connection(
+                    "django.core.mail.backends.smtp.EmailBackend"
+                ),
+            )

+ 28 - 17
api/desecapi/management/commands/stop-abuse.py

@@ -7,49 +7,60 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 class Command(BaseCommand):
-    help = 'Removes all DNS records from domains given either by name or by email address of their owner. ' \
-           'Locks all implicated user accounts.'
+    help = (
+        "Removes all DNS records from domains given either by name or by email address of their owner. "
+        "Locks all implicated user accounts."
+    )
 
     def add_arguments(self, parser):
-        parser.add_argument('names', nargs='*',
-                            help='Domain(s) and User(s) to truncate and disable identified by name and email addresses')
+        parser.add_argument(
+            "names",
+            nargs="*",
+            help="Domain(s) and User(s) to truncate and disable identified by name and email addresses",
+        )
 
     def handle(self, *args, **options):
         with PDNSChangeTracker():
             # domains to truncate: all domains given and all domains belonging to a user given
             domains = Domain.objects.filter(
-                Q(name__in=options['names']) |
-                Q(owner__email__in=options['names'])
+                Q(name__in=options["names"]) | Q(owner__email__in=options["names"])
             )
-            domain_names = domains.distinct().values_list('name', flat=True)
+            domain_names = domains.distinct().values_list("name", flat=True)
 
             # users to lock: all associated with any of the domains and all given
             users = User.objects.filter(
-                Q(domains__name__in=options['names']) |
-                Q(email__in=options['names'])
+                Q(domains__name__in=options["names"]) | Q(email__in=options["names"])
             )
-            user_emails = users.distinct().values_list('email', flat=True)
+            user_emails = users.distinct().values_list("email", flat=True)
 
             # rrsets to delete: all belonging to (all domains given and all domains belonging to a user given)
             rrsets = RRset.objects.filter(
-                Q(domain__name__in=options['names']) |
-                Q(domain__owner__email__in=options['names'])
+                Q(domain__name__in=options["names"])
+                | Q(domain__owner__email__in=options["names"])
             )
 
             # Print summary
-            print(f'Deleting {rrsets.distinct().count()} RRset(s) from {domains.distinct().count()} domain(s); '
-                  f'disabling {users.distinct().count()} associated user account(s).')
+            print(
+                f"Deleting {rrsets.distinct().count()} RRset(s) from {domains.distinct().count()} domain(s); "
+                f"disabling {users.distinct().count()} associated user account(s)."
+            )
 
             # Print details
             for d in domain_names:
-                print(f'Truncating domain {d}')
+                print(f"Truncating domain {d}")
             for e in user_emails:
-                print(f'Locking user {e}')
+                print(f"Locking user {e}")
 
             # delete rrsets and create default NS records
             rrsets.delete()
             for d in domains:
-                RRset.objects.create(domain=d, subname='', type='NS', ttl=3600, contents=settings.DEFAULT_NS)
+                RRset.objects.create(
+                    domain=d,
+                    subname="",
+                    type="NS",
+                    ttl=3600,
+                    contents=settings.DEFAULT_NS,
+                )
 
         # lock users
         users.update(is_active=False)

+ 17 - 14
api/desecapi/management/commands/sync-from-pdns.py

@@ -6,31 +6,34 @@ from desecapi.models import Domain, RRset, RR, RR_SET_TYPES_AUTOMATIC
 
 
 class Command(BaseCommand):
-    help = 'Import authoritative data from pdns, making the local database consistent with pdns.'
+    help = "Import authoritative data from pdns, making the local database consistent with pdns."
 
     def add_arguments(self, parser):
-        parser.add_argument('domain-name', nargs='*',
-                            help='Domain name to import. If omitted, will import all domains that are known locally.')
+        parser.add_argument(
+            "domain-name",
+            nargs="*",
+            help="Domain name to import. If omitted, will import all domains that are known locally.",
+        )
 
     def handle(self, *args, **options):
         domains = Domain.objects.all()
 
-        if options['domain-name']:
-            domains = domains.filter(name__in=options['domain-name'])
-            domain_names = domains.values_list('name', flat=True)
+        if options["domain-name"]:
+            domains = domains.filter(name__in=options["domain-name"])
+            domain_names = domains.values_list("name", flat=True)
 
-            for domain_name in options['domain-name']:
+            for domain_name in options["domain-name"]:
                 if domain_name not in domain_names:
-                    raise CommandError('{} is not a known domain'.format(domain_name))
+                    raise CommandError("{} is not a known domain".format(domain_name))
 
         for domain in domains:
-            self.stdout.write('%s ...' % domain.name, ending='')
+            self.stdout.write("%s ..." % domain.name, ending="")
             try:
                 self._sync_domain(domain)
-                self.stdout.write(' synced')
+                self.stdout.write(" synced")
             except Exception as e:
-                self.stdout.write(' failed')
-                msg = 'Error while processing {}: {}'.format(domain.name, e)
+                self.stdout.write(" failed")
+                msg = "Error while processing {}: {}".format(domain.name, e)
                 raise CommandError(msg)
 
     @staticmethod
@@ -40,9 +43,9 @@ class Command(BaseCommand):
         rrsets = []
         rrs = []
         for rrset_data in pdns.get_rrset_datas(domain):
-            if rrset_data['type'] in RR_SET_TYPES_AUTOMATIC:
+            if rrset_data["type"] in RR_SET_TYPES_AUTOMATIC:
                 continue
-            records = rrset_data.pop('records')
+            records = rrset_data.pop("records")
             rrset = RRset(**rrset_data)
             rrsets.append(rrset)
             rrs.extend([RR(rrset=rrset, content=record) for record in records])

+ 30 - 19
api/desecapi/management/commands/sync-to-pdns.py

@@ -8,39 +8,42 @@ from desecapi.pdns_change_tracker import PDNSChangeTracker
 
 
 class Command(BaseCommand):
-    help = 'Sync RRsets from local API database to pdns.'
+    help = "Sync RRsets from local API database to pdns."
 
     def add_arguments(self, parser):
-        parser.add_argument('domain-name', nargs='*',
-                            help='Domain name to sync. If omitted, will import all API domains.')
+        parser.add_argument(
+            "domain-name",
+            nargs="*",
+            help="Domain name to sync. If omitted, will import all API domains.",
+        )
 
     def handle(self, *args, **options):
         domains = Domain.objects.all()
 
-        if options['domain-name']:
-            domains = domains.filter(name__in=options['domain-name'])
-            domain_names = domains.values_list('name', flat=True)
+        if options["domain-name"]:
+            domains = domains.filter(name__in=options["domain-name"])
+            domain_names = domains.values_list("name", flat=True)
 
-            for domain_name in options['domain-name']:
+            for domain_name in options["domain-name"]:
                 if domain_name not in domain_names:
-                    raise CommandError('{} is not a known domain'.format(domain_name))
+                    raise CommandError("{} is not a known domain".format(domain_name))
 
         catalog_alignment = False
         for domain in domains:
-            self.stdout.write('%s ...' % domain.name, ending='')
+            self.stdout.write("%s ..." % domain.name, ending="")
             try:
                 created = self._sync_domain(domain)
                 if created:
-                    self.stdout.write(f' created (was missing) ...', ending='')
+                    self.stdout.write(f" created (was missing) ...", ending="")
                     catalog_alignment = True
-                self.stdout.write(' synced')
+                self.stdout.write(" synced")
             except Exception as e:
-                self.stdout.write(' failed')
-                msg = 'Error while processing {}: {}'.format(domain.name, e)
+                self.stdout.write(" failed")
+                msg = "Error while processing {}: {}".format(domain.name, e)
                 raise CommandError(msg)
 
         if catalog_alignment:
-            call_command('align-catalog-zone')
+            call_command("align-catalog-zone")
 
     @staticmethod
     @transaction.atomic
@@ -60,12 +63,20 @@ class Command(BaseCommand):
             created = True
 
         # modifications actually merged with additions in CreateUpdateDeleteRRSets
-        modifications = {(rrset.type, rrset.subname) for rrset in domain.rrset_set.all()}
-        deletions = {(rrset['type'], rrset['subname']) for rrset in pdns.get_rrset_datas(domain)} - modifications
-        deletions.discard(('SOA', ''))  # do not remove SOA record
+        modifications = {
+            (rrset.type, rrset.subname) for rrset in domain.rrset_set.all()
+        }
+        deletions = {
+            (rrset["type"], rrset["subname"]) for rrset in pdns.get_rrset_datas(domain)
+        } - modifications
+        deletions.discard(("SOA", ""))  # do not remove SOA record
 
         # Update zone on nslord, propagate to nsmaster
-        PDNSChangeTracker.CreateUpdateDeleteRRSets(domain.name, set(), modifications, deletions).pdns_do()
-        pdns._pdns_put(pdns.NSMASTER, '/zones/{}/axfr-retrieve'.format(pdns.pdns_id(domain.name)))
+        PDNSChangeTracker.CreateUpdateDeleteRRSets(
+            domain.name, set(), modifications, deletions
+        ).pdns_do()
+        pdns._pdns_put(
+            pdns.NSMASTER, "/zones/{}/axfr-retrieve".format(pdns.pdns_id(domain.name))
+        )
 
         return created

+ 42 - 13
api/desecapi/metrics.py

@@ -16,28 +16,57 @@ def set_histogram(name, *args, **kwargs):
 
 
 # models.py metrics
-set_counter('desecapi_captcha_content_created', 'number of times captcha content created', ['kind'])
-set_counter('desecapi_autodelegation_created', 'number of autodelegations added')
-set_counter('desecapi_autodelegation_deleted', 'number of autodelegations deleted')
-set_histogram('desecapi_messages_queued', 'number of emails queued', ['reason', 'user', 'lane'],
-              buckets=[0, 1, float("inf")])
+set_counter(
+    "desecapi_captcha_content_created",
+    "number of times captcha content created",
+    ["kind"],
+)
+set_counter("desecapi_autodelegation_created", "number of autodelegations added")
+set_counter("desecapi_autodelegation_deleted", "number of autodelegations deleted")
+set_histogram(
+    "desecapi_messages_queued",
+    "number of emails queued",
+    ["reason", "user", "lane"],
+    buckets=[0, 1, float("inf")],
+)
 
 # views.py metrics
-set_counter('desecapi_dynDNS12_domain_not_found', 'number of times dynDNS12 domain is not found')
+set_counter(
+    "desecapi_dynDNS12_domain_not_found", "number of times dynDNS12 domain is not found"
+)
 
 # crypto.py metrics
-set_counter('desecapi_key_encryption_success', 'number of times key encryption was successful', ['context'])
-set_counter('desecapi_key_decryption_success', 'number of times key decryption was successful', ['context'])
+set_counter(
+    "desecapi_key_encryption_success",
+    "number of times key encryption was successful",
+    ["context"],
+)
+set_counter(
+    "desecapi_key_decryption_success",
+    "number of times key decryption was successful",
+    ["context"],
+)
 
 # exception_handlers.py metrics
-set_counter('desecapi_database_unavailable', 'number of times database was unavailable')
+set_counter("desecapi_database_unavailable", "number of times database was unavailable")
 
 # pdns.py metrics
-set_counter('desecapi_pdns_request_success', 'number of times pdns request was successful', ['method', 'status'])
-set_counter('desecapi_pdns_keys_fetched', 'number of times pdns keys were fetched')
+set_counter(
+    "desecapi_pdns_request_success",
+    "number of times pdns request was successful",
+    ["method", "status"],
+)
+set_counter("desecapi_pdns_keys_fetched", "number of times pdns keys were fetched")
 
 # pdns_change_tracker.py metrics
-set_counter('desecapi_pdns_catalog_updated', 'number of times pdns catalog was updated successfully')
+set_counter(
+    "desecapi_pdns_catalog_updated",
+    "number of times pdns catalog was updated successfully",
+)
 
 # throttling.py metrics
-set_counter('desecapi_throttle_failure', 'number of requests throttled', ['method', 'scope', 'user', 'bucket'])
+set_counter(
+    "desecapi_throttle_failure",
+    "number of requests throttled",
+    ["method", "scope", "user", "bucket"],
+)

+ 371 - 100
api/desecapi/migrations/0001_initial_squashed_again.py

@@ -13,198 +13,469 @@ class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies = [
-    ]
+    dependencies = []
 
     operations = [
         migrations.CreateModel(
-            name='User',
+            name="User",
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('password', models.CharField(max_length=128, verbose_name='password')),
-                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
-                ('email', models.EmailField(max_length=191, unique=True, verbose_name='email address')),
-                ('is_active', models.BooleanField(default=True)),
-                ('is_admin', models.BooleanField(default=False)),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('limit_domains', models.IntegerField(blank=True, default=desecapi.models.User._limit_domains_default, null=True)),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("password", models.CharField(max_length=128, verbose_name="password")),
+                (
+                    "last_login",
+                    models.DateTimeField(
+                        blank=True, null=True, verbose_name="last login"
+                    ),
+                ),
+                (
+                    "email",
+                    models.EmailField(
+                        max_length=191, unique=True, verbose_name="email address"
+                    ),
+                ),
+                ("is_active", models.BooleanField(default=True)),
+                ("is_admin", models.BooleanField(default=False)),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "limit_domains",
+                    models.IntegerField(
+                        blank=True,
+                        default=desecapi.models.User._limit_domains_default,
+                        null=True,
+                    ),
+                ),
             ],
             options={
-                'abstract': False,
+                "abstract": False,
             },
         ),
         migrations.CreateModel(
-            name='Domain',
+            name="Domain",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('name', models.CharField(max_length=191, unique=True, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_domain_name', flags=re.RegexFlag['IGNORECASE'], message='Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, hyphens, and underscores. The last label may not contain an underscore.', regex='^(([a-z0-9_-]{1,63})\\.)*[a-z0-9-]{1,63}$')])),
-                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='domains', to=settings.AUTH_USER_MODEL)),
-                ('published', models.DateTimeField(blank=True, null=True)),
-                ('minimum_ttl', models.PositiveIntegerField(default=desecapi.models.Domain._minimum_ttl_default)),
-                ('renewal_changed', models.DateTimeField(auto_now_add=True)),
-                ('renewal_state', models.IntegerField(choices=[(1, 'Fresh'), (2, 'Notified'), (3, 'Warned')], default=1)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "name",
+                    models.CharField(
+                        max_length=191,
+                        unique=True,
+                        validators=[
+                            desecapi.models.validate_lower,
+                            django.core.validators.RegexValidator(
+                                code="invalid_domain_name",
+                                flags=re.RegexFlag["IGNORECASE"],
+                                message="Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, hyphens, and underscores. The last label may not contain an underscore.",
+                                regex="^(([a-z0-9_-]{1,63})\\.)*[a-z0-9-]{1,63}$",
+                            ),
+                        ],
+                    ),
+                ),
+                (
+                    "owner",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        related_name="domains",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+                ("published", models.DateTimeField(blank=True, null=True)),
+                (
+                    "minimum_ttl",
+                    models.PositiveIntegerField(
+                        default=desecapi.models.Domain._minimum_ttl_default
+                    ),
+                ),
+                ("renewal_changed", models.DateTimeField(auto_now_add=True)),
+                (
+                    "renewal_state",
+                    models.IntegerField(
+                        choices=[(1, "Fresh"), (2, "Notified"), (3, "Warned")],
+                        default=1,
+                    ),
+                ),
             ],
             options={
-                'ordering': ('created',),
+                "ordering": ("created",),
             },
         ),
         migrations.CreateModel(
-            name='RRset',
+            name="RRset",
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('touched', models.DateTimeField(auto_now=True)),
-                ('subname', models.CharField(blank=True, max_length=178, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_subname', message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.", regex='^([*]|(([*][.])?[a-z0-9_.-]*))$')])),
-                ('type', models.CharField(max_length=10, validators=[desecapi.models.validate_upper, django.core.validators.RegexValidator(code='invalid_type', message='Type must be uppercase alphanumeric and start with a letter.', regex='^[A-Z][A-Z0-9]*$')])),
-                ('ttl', models.PositiveIntegerField()),
-                ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='desecapi.domain')),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("touched", models.DateTimeField(auto_now=True)),
+                (
+                    "subname",
+                    models.CharField(
+                        blank=True,
+                        max_length=178,
+                        validators=[
+                            desecapi.models.validate_lower,
+                            django.core.validators.RegexValidator(
+                                code="invalid_subname",
+                                message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.",
+                                regex="^([*]|(([*][.])?[a-z0-9_.-]*))$",
+                            ),
+                        ],
+                    ),
+                ),
+                (
+                    "type",
+                    models.CharField(
+                        max_length=10,
+                        validators=[
+                            desecapi.models.validate_upper,
+                            django.core.validators.RegexValidator(
+                                code="invalid_type",
+                                message="Type must be uppercase alphanumeric and start with a letter.",
+                                regex="^[A-Z][A-Z0-9]*$",
+                            ),
+                        ],
+                    ),
+                ),
+                ("ttl", models.PositiveIntegerField()),
+                (
+                    "domain",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="desecapi.domain",
+                    ),
+                ),
             ],
             options={
-                'unique_together': {('domain', 'subname', 'type')},
+                "unique_together": {("domain", "subname", "type")},
             },
         ),
         migrations.CreateModel(
-            name='AuthenticatedAction',
+            name="AuthenticatedAction",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
         ),
         migrations.CreateModel(
-            name='AuthenticatedUserAction',
+            name="AuthenticatedUserAction",
             fields=[
-                ('authenticatedaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedaction')),
+                (
+                    "authenticatedaction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedaction",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticatedaction',),
+            bases=("desecapi.authenticatedaction",),
         ),
         migrations.CreateModel(
-            name='AuthenticatedDeleteUserAction',
+            name="AuthenticatedDeleteUserAction",
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         migrations.CreateModel(
-            name='AuthenticatedResetPasswordUserAction',
+            name="AuthenticatedResetPasswordUserAction",
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
-                ('new_password', models.CharField(max_length=128)),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
+                ("new_password", models.CharField(max_length=128)),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         migrations.CreateModel(
-            name='Captcha',
+            name="Captcha",
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('content', models.CharField(default=desecapi.models.captcha.captcha_default_content, max_length=24)),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                (
+                    "content",
+                    models.CharField(
+                        default=desecapi.models.captcha.captcha_default_content,
+                        max_length=24,
+                    ),
+                ),
             ],
         ),
         migrations.CreateModel(
-            name='Token',
+            name="Token",
             fields=[
-                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
-                ('created', models.DateTimeField(auto_now_add=True, verbose_name='Created')),
-                ('key', models.CharField(db_index=True, max_length=128, unique=True, verbose_name='Key')),
-                ('name', models.CharField(blank=True, max_length=64, verbose_name='Name')),
-                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_tokens', to=settings.AUTH_USER_MODEL, verbose_name='User')),
-                ('last_used', models.DateTimeField(blank=True, null=True)),
+                (
+                    "id",
+                    models.UUIDField(
+                        default=uuid.uuid4,
+                        editable=False,
+                        primary_key=True,
+                        serialize=False,
+                    ),
+                ),
+                (
+                    "created",
+                    models.DateTimeField(auto_now_add=True, verbose_name="Created"),
+                ),
+                (
+                    "key",
+                    models.CharField(
+                        db_index=True, max_length=128, unique=True, verbose_name="Key"
+                    ),
+                ),
+                (
+                    "name",
+                    models.CharField(blank=True, max_length=64, verbose_name="Name"),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="auth_tokens",
+                        to=settings.AUTH_USER_MODEL,
+                        verbose_name="User",
+                    ),
+                ),
+                ("last_used", models.DateTimeField(blank=True, null=True)),
             ],
             options={
-                'verbose_name': 'Token',
-                'verbose_name_plural': 'Tokens',
+                "verbose_name": "Token",
+                "verbose_name_plural": "Tokens",
             },
         ),
         migrations.CreateModel(
-            name='RR',
+            name="RR",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('created', models.DateTimeField(auto_now_add=True)),
-                ('content', models.CharField(max_length=500)),
-                ('rrset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='records', to='desecapi.rrset')),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("created", models.DateTimeField(auto_now_add=True)),
+                ("content", models.CharField(max_length=500)),
+                (
+                    "rrset",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="records",
+                        to="desecapi.rrset",
+                    ),
+                ),
             ],
         ),
         migrations.CreateModel(
-            name='AuthenticatedActivateUserAction',
+            name="AuthenticatedActivateUserAction",
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
-                ('domain', models.CharField(max_length=191)),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
+                ("domain", models.CharField(max_length=191)),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         migrations.CreateModel(
-            name='AuthenticatedChangeEmailUserAction',
+            name="AuthenticatedChangeEmailUserAction",
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
-                ('new_email', models.EmailField(max_length=254)),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
+                ("new_email", models.EmailField(max_length=254)),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
         migrations.CreateModel(
-            name='AuthenticatedBasicUserAction',
+            name="AuthenticatedBasicUserAction",
             fields=[
-                ('authenticatedaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedaction')),
+                (
+                    "authenticatedaction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedaction",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticatedaction',),
+            bases=("desecapi.authenticatedaction",),
         ),
         migrations.CreateModel(
-            name='AuthenticatedDomainBasicUserAction',
+            name="AuthenticatedDomainBasicUserAction",
             fields=[
-                ('authenticatedbasicuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedbasicuseraction')),
+                (
+                    "authenticatedbasicuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedbasicuseraction",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticatedbasicuseraction',),
+            bases=("desecapi.authenticatedbasicuseraction",),
         ),
         migrations.CreateModel(
-            name='AuthenticatedRenewDomainBasicUserAction',
+            name="AuthenticatedRenewDomainBasicUserAction",
             fields=[
-                ('authenticateddomainbasicuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateddomainbasicuseraction')),
+                (
+                    "authenticateddomainbasicuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateddomainbasicuseraction",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticateddomainbasicuseraction',),
+            bases=("desecapi.authenticateddomainbasicuseraction",),
         ),
         migrations.CreateModel(
-            name='Donation',
+            name="Donation",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('created', models.DateTimeField(default=desecapi.models.Donation._created_default)),
-                ('name', models.CharField(max_length=255)),
-                ('iban', models.CharField(max_length=34)),
-                ('bic', models.CharField(max_length=11)),
-                ('amount', models.DecimalField(decimal_places=2, max_digits=8)),
-                ('message', models.CharField(blank=True, max_length=255)),
-                ('due', models.DateTimeField(default=desecapi.models.Donation._due_default)),
-                ('mref', models.CharField(default=desecapi.models.Donation._mref_default, max_length=32)),
-                ('email', models.EmailField(blank=True, max_length=255)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "created",
+                    models.DateTimeField(
+                        default=desecapi.models.Donation._created_default
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                ("iban", models.CharField(max_length=34)),
+                ("bic", models.CharField(max_length=11)),
+                ("amount", models.DecimalField(decimal_places=2, max_digits=8)),
+                ("message", models.CharField(blank=True, max_length=255)),
+                (
+                    "due",
+                    models.DateTimeField(default=desecapi.models.Donation._due_default),
+                ),
+                (
+                    "mref",
+                    models.CharField(
+                        default=desecapi.models.Donation._mref_default, max_length=32
+                    ),
+                ),
+                ("email", models.EmailField(blank=True, max_length=255)),
             ],
             options={
-                'ordering': ('created',),
-                'managed': False,
+                "ordering": ("created",),
+                "managed": False,
             },
         ),
     ]

+ 3 - 3
api/desecapi/migrations/0002_unmanaged_donations.py

@@ -9,12 +9,12 @@ from django.db import migrations
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0001_initial_squashed_again'),
+        ("desecapi", "0001_initial_squashed_again"),
     ]
 
     operations = [
         migrations.AlterModelOptions(
-            name='donation',
-            options={'managed': False},
+            name="donation",
+            options={"managed": False},
         ),
     ]

+ 3 - 3
api/desecapi/migrations/0003_rr_content.py

@@ -6,13 +6,13 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0002_unmanaged_donations'),
+        ("desecapi", "0002_unmanaged_donations"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='rr',
-            name='content',
+            model_name="rr",
+            name="content",
             field=models.TextField(),
         ),
     ]

+ 7 - 4
api/desecapi/migrations/0004_immortal_domains.py

@@ -6,13 +6,16 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0003_rr_content'),
+        ("desecapi", "0003_rr_content"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='domain',
-            name='renewal_state',
-            field=models.IntegerField(choices=[(0, 'Immortal'), (1, 'Fresh'), (2, 'Notified'), (3, 'Warned')], default=0),
+            model_name="domain",
+            name="renewal_state",
+            field=models.IntegerField(
+                choices=[(0, "Immortal"), (1, "Fresh"), (2, "Notified"), (3, "Warned")],
+                default=0,
+            ),
         ),
     ]

+ 15 - 4
api/desecapi/migrations/0005_subname_validation.py

@@ -8,13 +8,24 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0004_immortal_domains'),
+        ("desecapi", "0004_immortal_domains"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='rrset',
-            name='subname',
-            field=models.CharField(blank=True, max_length=178, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_subname', message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.", regex='^([*]|(([*][.])?([a-z0-9_-]+[.])*[a-z0-9_-]+))$')]),
+            model_name="rrset",
+            name="subname",
+            field=models.CharField(
+                blank=True,
+                max_length=178,
+                validators=[
+                    desecapi.models.validate_lower,
+                    django.core.validators.RegexValidator(
+                        code="invalid_subname",
+                        message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'.",
+                        regex="^([*]|(([*][.])?([a-z0-9_-]+[.])*[a-z0-9_-]+))$",
+                    ),
+                ],
+            ),
         ),
     ]

+ 13 - 3
api/desecapi/migrations/0006_cname_exclusivity.py

@@ -9,13 +9,23 @@ import django.db.models.expressions
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0005_subname_validation'),
+        ("desecapi", "0005_subname_validation"),
     ]
 
     operations = [
         BtreeGistExtension(),
         migrations.AddConstraint(
-            model_name='rrset',
-            constraint=django.contrib.postgres.constraints.ExclusionConstraint(expressions=[('domain', '='), ('subname', '='), (django.db.models.expressions.RawSQL("int4(type = 'CNAME')", ()), '<>')], name='cname_exclusivity'),
+            model_name="rrset",
+            constraint=django.contrib.postgres.constraints.ExclusionConstraint(
+                expressions=[
+                    ("domain", "="),
+                    ("subname", "="),
+                    (
+                        django.db.models.expressions.RawSQL("int4(type = 'CNAME')", ()),
+                        "<>",
+                    ),
+                ],
+                name="cname_exclusivity",
+            ),
         ),
     ]

+ 6 - 4
api/desecapi/migrations/0007_email_citext.py

@@ -8,14 +8,16 @@ from django.db import migrations
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0006_cname_exclusivity'),
+        ("desecapi", "0006_cname_exclusivity"),
     ]
 
     operations = [
         CITextExtension(),
         migrations.AlterField(
-            model_name='user',
-            name='email',
-            field=django.contrib.postgres.fields.citext.CIEmailField(max_length=254, unique=True, verbose_name='email address'),
+            model_name="user",
+            name="email",
+            field=django.contrib.postgres.fields.citext.CIEmailField(
+                max_length=254, unique=True, verbose_name="email address"
+            ),
         ),
     ]

+ 5 - 5
api/desecapi/migrations/0008_token_perm_manage_tokens.py

@@ -6,18 +6,18 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0007_email_citext'),
+        ("desecapi", "0007_email_citext"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='token',
-            name='perm_manage_tokens',
+            model_name="token",
+            name="perm_manage_tokens",
             field=models.BooleanField(default=True),
         ),
         migrations.AlterField(
-            model_name='token',
-            name='perm_manage_tokens',
+            model_name="token",
+            name="perm_manage_tokens",
             field=models.BooleanField(default=False),
         ),
     ]

+ 8 - 4
api/desecapi/migrations/0009_token_allowed_subnets.py

@@ -9,13 +9,17 @@ import netfields.fields
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0008_token_perm_manage_tokens'),
+        ("desecapi", "0008_token_perm_manage_tokens"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='token',
-            name='allowed_subnets',
-            field=django.contrib.postgres.fields.ArrayField(base_field=netfields.fields.CidrAddressField(max_length=43), default=desecapi.models.Token._allowed_subnets_default, size=None),
+            model_name="token",
+            name="allowed_subnets",
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=netfields.fields.CidrAddressField(max_length=43),
+                default=desecapi.models.Token._allowed_subnets_default,
+                size=None,
+            ),
         ),
     ]

+ 19 - 7
api/desecapi/migrations/0010_token_expiration.py

@@ -8,18 +8,30 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0009_token_allowed_subnets'),
+        ("desecapi", "0009_token_allowed_subnets"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='token',
-            name='max_age',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0))]),
+            model_name="token",
+            name="max_age",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0))
+                ],
+            ),
         ),
         migrations.AddField(
-            model_name='token',
-            name='max_unused_period',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0))]),
+            model_name="token",
+            name="max_unused_period",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0))
+                ],
+            ),
         ),
     ]

+ 11 - 7
api/desecapi/migrations/0011_captcha_kind.py

@@ -6,18 +6,22 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0010_token_expiration'),
+        ("desecapi", "0010_token_expiration"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='captcha',
-            name='kind',
-            field=models.CharField(choices=[('image', 'Image'), ('audio', 'Audio')], default='image', max_length=24),
+            model_name="captcha",
+            name="kind",
+            field=models.CharField(
+                choices=[("image", "Image"), ("audio", "Audio")],
+                default="image",
+                max_length=24,
+            ),
         ),
         migrations.AlterField(
-            model_name='captcha',
-            name='content',
-            field=models.CharField(default='', max_length=24),
+            model_name="captcha",
+            name="content",
+            field=models.CharField(default="", max_length=24),
         ),
     ]

+ 15 - 4
api/desecapi/migrations/0012_rrset_label_length.py

@@ -8,13 +8,24 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0011_captcha_kind'),
+        ("desecapi", "0011_captcha_kind"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='rrset',
-            name='subname',
-            field=models.CharField(blank=True, max_length=178, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_subname', message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'. Components may not exceed 63 characters.", regex='^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$')]),
+            model_name="rrset",
+            name="subname",
+            field=models.CharField(
+                blank=True,
+                max_length=178,
+                validators=[
+                    desecapi.models.validate_lower,
+                    django.core.validators.RegexValidator(
+                        code="invalid_subname",
+                        message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, may start with a '*.', or just be '*'. Components may not exceed 63 characters.",
+                        regex="^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$",
+                    ),
+                ],
+            ),
         ),
     ]

+ 5 - 5
api/desecapi/migrations/0013_user_needs_captcha.py

@@ -6,18 +6,18 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0012_rrset_label_length'),
+        ("desecapi", "0012_rrset_label_length"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='needs_captcha',
+            model_name="user",
+            name="needs_captcha",
             field=models.BooleanField(default=False),
         ),
         migrations.AlterField(
-            model_name='user',
-            name='needs_captcha',
+            model_name="user",
+            name="needs_captcha",
             field=models.BooleanField(default=True),
         ),
     ]

+ 5 - 5
api/desecapi/migrations/0014_replication.py

@@ -6,18 +6,18 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0013_user_needs_captcha'),
+        ("desecapi", "0013_user_needs_captcha"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='domain',
-            name='replicated',
+            model_name="domain",
+            name="replicated",
             field=models.DateTimeField(blank=True, null=True),
         ),
         migrations.AddField(
-            model_name='domain',
-            name='replication_duration',
+            model_name="domain",
+            name="replication_duration",
             field=models.DurationField(blank=True, null=True),
         ),
     ]

+ 3 - 3
api/desecapi/migrations/0015_rrset_touched_index.py

@@ -6,13 +6,13 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0014_replication'),
+        ("desecapi", "0014_replication"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='rrset',
-            name='touched',
+            model_name="rrset",
+            name="touched",
             field=models.DateTimeField(auto_now=True, db_index=True),
         ),
     ]

+ 11 - 7
api/desecapi/migrations/0016_default_auto_field.py

@@ -6,18 +6,22 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0015_rrset_touched_index'),
+        ("desecapi", "0015_rrset_touched_index"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='domain',
-            name='id',
-            field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+            model_name="domain",
+            name="id",
+            field=models.BigAutoField(
+                auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+            ),
         ),
         migrations.AlterField(
-            model_name='rr',
-            name='id',
-            field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+            model_name="rr",
+            name="id",
+            field=models.BigAutoField(
+                auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+            ),
         ),
     ]

+ 8 - 4
api/desecapi/migrations/0017_alter_user_limit_domains.py

@@ -7,13 +7,17 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0016_default_auto_field'),
+        ("desecapi", "0016_default_auto_field"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='user',
-            name='limit_domains',
-            field=models.PositiveIntegerField(blank=True, default=desecapi.models.User._limit_domains_default, null=True),
+            model_name="user",
+            name="limit_domains",
+            field=models.PositiveIntegerField(
+                blank=True,
+                default=desecapi.models.User._limit_domains_default,
+                null=True,
+            ),
         ),
     ]

+ 69 - 27
api/desecapi/migrations/0018_tokendomainpolicy.py

@@ -9,56 +9,98 @@ import django.db.models.expressions
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0017_alter_user_limit_domains'),
+        ("desecapi", "0017_alter_user_limit_domains"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='token',
-            name='user',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+            model_name="token",
+            name="user",
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+            ),
         ),
         migrations.CreateModel(
-            name='TokenDomainPolicy',
+            name="TokenDomainPolicy",
             fields=[
-                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('perm_dyndns', models.BooleanField(default=False)),
-                ('perm_rrsets', models.BooleanField(default=False)),
-                ('domain', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='desecapi.domain')),
-                ('token', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='desecapi.token')),
-                ('token_user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("perm_dyndns", models.BooleanField(default=False)),
+                ("perm_rrsets", models.BooleanField(default=False)),
+                (
+                    "domain",
+                    models.ForeignKey(
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="desecapi.domain",
+                    ),
+                ),
+                (
+                    "token",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to="desecapi.token"
+                    ),
+                ),
+                (
+                    "token_user",
+                    models.ForeignKey(
+                        db_constraint=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
         ),
         migrations.AddField(
-            model_name='token',
-            name='domain_policies',
-            field=models.ManyToManyField(through='desecapi.TokenDomainPolicy', to='desecapi.Domain'),
+            model_name="token",
+            name="domain_policies",
+            field=models.ManyToManyField(
+                through="desecapi.TokenDomainPolicy", to="desecapi.Domain"
+            ),
         ),
         migrations.AddConstraint(
-            model_name='tokendomainpolicy',
-            constraint=models.UniqueConstraint(fields=('token', 'domain'), name='unique_entry'),
+            model_name="tokendomainpolicy",
+            constraint=models.UniqueConstraint(
+                fields=("token", "domain"), name="unique_entry"
+            ),
         ),
         migrations.AddConstraint(
-            model_name='tokendomainpolicy',
-            constraint=models.UniqueConstraint(condition=models.Q(('domain__isnull', True)), fields=('token',), name='unique_entry_null_domain'),
+            model_name="tokendomainpolicy",
+            constraint=models.UniqueConstraint(
+                condition=models.Q(("domain__isnull", True)),
+                fields=("token",),
+                name="unique_entry_null_domain",
+            ),
         ),
         # The remaining operations ensure that domain.owner and token.user can't be inconsistent
         migrations.AlterModelOptions(
-            name='token',
+            name="token",
             options={},
         ),
         migrations.AddConstraint(
-            model_name='token',
-            constraint=models.UniqueConstraint(fields=('id', 'user'), name='unique_id_user'),
+            model_name="token",
+            constraint=models.UniqueConstraint(
+                fields=("id", "user"), name="unique_id_user"
+            ),
         ),
         migrations.AddConstraint(
-            model_name='domain',
-            constraint=models.UniqueConstraint(fields=('id', 'owner'), name='unique_id_owner'),
+            model_name="domain",
+            constraint=models.UniqueConstraint(
+                fields=("id", "owner"), name="unique_id_owner"
+            ),
         ),
         migrations.RunSQL(
-           "ALTER TABLE desecapi_tokendomainpolicy"
-           " ADD FOREIGN KEY ( domain_id, token_user_id ) REFERENCES desecapi_domain ( id, owner_id ),"
-           " ADD FOREIGN KEY ( token_id, token_user_id ) REFERENCES desecapi_token ( id, user_id );",
-           migrations.RunSQL.noop
+            "ALTER TABLE desecapi_tokendomainpolicy"
+            " ADD FOREIGN KEY ( domain_id, token_user_id ) REFERENCES desecapi_domain ( id, owner_id ),"
+            " ADD FOREIGN KEY ( token_id, token_user_id ) REFERENCES desecapi_token ( id, user_id );",
+            migrations.RunSQL.noop,
         ),
     ]

+ 6 - 4
api/desecapi/migrations/0019_alter_user_is_active.py

@@ -6,7 +6,9 @@ from django.db import migrations, models
 def forwards_func(apps, schema_editor):
     User = apps.get_model("desecapi", "User")
     db_alias = schema_editor.connection.alias
-    User.objects.using(db_alias).filter(is_active=False, last_login__isnull=True).update(is_active=None)
+    User.objects.using(db_alias).filter(
+        is_active=False, last_login__isnull=True
+    ).update(is_active=None)
 
 
 def reverse_func(apps, schema_editor):
@@ -18,13 +20,13 @@ def reverse_func(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0018_tokendomainpolicy'),
+        ("desecapi", "0018_tokendomainpolicy"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='user',
-            name='is_active',
+            model_name="user",
+            name="is_active",
             field=models.BooleanField(default=True, null=True),
         ),
         migrations.RunPython(forwards_func, reverse_func),

+ 5 - 5
api/desecapi/migrations/0020_user_email_verified.py

@@ -11,20 +11,20 @@ def forwards_func(apps, schema_editor):
     db_alias = schema_editor.connection.alias
     User.objects.using(db_alias).filter(
         Q(is_active=True) | Q(last_login__isnull=False),
-        created__date__gte=datetime.date(2019, 11, 1)
-    ).update(email_verified=F('created'))
+        created__date__gte=datetime.date(2019, 11, 1),
+    ).update(email_verified=F("created"))
 
 
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0019_alter_user_is_active'),
+        ("desecapi", "0019_alter_user_is_active"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='email_verified',
+            model_name="user",
+            name="email_verified",
             field=models.DateTimeField(blank=True, null=True),
         ),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),

+ 15 - 5
api/desecapi/migrations/0021_authenticatednoopuseraction.py

@@ -7,18 +7,28 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0020_user_email_verified'),
+        ("desecapi", "0020_user_email_verified"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='AuthenticatedNoopUserAction',
+            name="AuthenticatedNoopUserAction",
             fields=[
-                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticateduseraction')),
+                (
+                    "authenticateduseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticateduseraction",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticateduseraction',),
+            bases=("desecapi.authenticateduseraction",),
         ),
     ]

+ 3 - 3
api/desecapi/migrations/0022_user_outreach_preference.py

@@ -6,13 +6,13 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0021_authenticatednoopuseraction'),
+        ("desecapi", "0021_authenticatednoopuseraction"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='outreach_preference',
+            model_name="user",
+            name="outreach_preference",
             field=models.BooleanField(default=True),
         ),
     ]

+ 15 - 5
api/desecapi/migrations/0023_authenticatedemailuseraction.py

@@ -7,18 +7,28 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0022_user_outreach_preference'),
+        ("desecapi", "0022_user_outreach_preference"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='AuthenticatedEmailUserAction',
+            name="AuthenticatedEmailUserAction",
             fields=[
-                ('authenticatedbasicuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedbasicuseraction')),
+                (
+                    "authenticatedbasicuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedbasicuseraction",
+                    ),
+                ),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticatedbasicuseraction',),
+            bases=("desecapi.authenticatedbasicuseraction",),
         ),
     ]

+ 16 - 6
api/desecapi/migrations/0024_authenticatedchangeoutreachpreferenceuseraction.py

@@ -7,19 +7,29 @@ import django.db.models.deletion
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0023_authenticatedemailuseraction'),
+        ("desecapi", "0023_authenticatedemailuseraction"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='AuthenticatedChangeOutreachPreferenceUserAction',
+            name="AuthenticatedChangeOutreachPreferenceUserAction",
             fields=[
-                ('authenticatedemailuseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.authenticatedemailuseraction')),
-                ('outreach_preference', models.BooleanField()),
+                (
+                    "authenticatedemailuseraction_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="desecapi.authenticatedemailuseraction",
+                    ),
+                ),
+                ("outreach_preference", models.BooleanField()),
             ],
             options={
-                'managed': False,
+                "managed": False,
             },
-            bases=('desecapi.authenticatedemailuseraction',),
+            bases=("desecapi.authenticatedemailuseraction",),
         ),
     ]

+ 31 - 9
api/desecapi/migrations/0025_alter_token_max_age_alter_token_max_unused_period.py

@@ -9,26 +9,48 @@ def forwards_func(apps, schema_editor):
     max_interval = datetime.timedelta(days=365000)
     Token = apps.get_model("desecapi", "Token")
     db_alias = schema_editor.connection.alias
-    Token.objects.using(db_alias).filter(max_age__gt=max_interval).update(max_age=max_interval)
-    Token.objects.using(db_alias).filter(max_unused_period__gt=max_interval).update(max_unused_period=max_interval)
+    Token.objects.using(db_alias).filter(max_age__gt=max_interval).update(
+        max_age=max_interval
+    )
+    Token.objects.using(db_alias).filter(max_unused_period__gt=max_interval).update(
+        max_unused_period=max_interval
+    )
 
 
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0024_authenticatedchangeoutreachpreferenceuseraction'),
+        ("desecapi", "0024_authenticatedchangeoutreachpreferenceuseraction"),
     ]
 
     operations = [
         migrations.AlterField(
-            model_name='token',
-            name='max_age',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0)), django.core.validators.MaxValueValidator(datetime.timedelta(days=365000))]),
+            model_name="token",
+            name="max_age",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0)),
+                    django.core.validators.MaxValueValidator(
+                        datetime.timedelta(days=365000)
+                    ),
+                ],
+            ),
         ),
         migrations.AlterField(
-            model_name='token',
-            name='max_unused_period',
-            field=models.DurationField(default=None, null=True, validators=[django.core.validators.MinValueValidator(datetime.timedelta(0)), django.core.validators.MaxValueValidator(datetime.timedelta(days=365000))]),
+            model_name="token",
+            name="max_unused_period",
+            field=models.DurationField(
+                default=None,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(datetime.timedelta(0)),
+                    django.core.validators.MaxValueValidator(
+                        datetime.timedelta(days=365000)
+                    ),
+                ],
+            ),
         ),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),
     ]

+ 5 - 5
api/desecapi/migrations/0026_remove_domain_replicated_and_more.py

@@ -6,16 +6,16 @@ from django.db import migrations
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0025_alter_token_max_age_alter_token_max_unused_period'),
+        ("desecapi", "0025_alter_token_max_age_alter_token_max_unused_period"),
     ]
 
     operations = [
         migrations.RemoveField(
-            model_name='domain',
-            name='replicated',
+            model_name="domain",
+            name="replicated",
         ),
         migrations.RemoveField(
-            model_name='domain',
-            name='replication_duration',
+            model_name="domain",
+            name="replication_duration",
         ),
     ]

+ 6 - 6
api/desecapi/migrations/0027_user_credentials_changed.py

@@ -7,25 +7,25 @@ from django.db.models import F
 def forwards_func(apps, schema_editor):
     User = apps.get_model("desecapi", "User")
     db_alias = schema_editor.connection.alias
-    User.objects.using(db_alias).update(credentials_changed=F('created'))
+    User.objects.using(db_alias).update(credentials_changed=F("created"))
 
 
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('desecapi', '0026_remove_domain_replicated_and_more'),
+        ("desecapi", "0026_remove_domain_replicated_and_more"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='credentials_changed',
+            model_name="user",
+            name="credentials_changed",
             field=models.DateTimeField(auto_now_add=True, null=True),
         ),
         migrations.RunPython(forwards_func, migrations.RunPython.noop),
         migrations.AlterField(
-            model_name='user',
-            name='credentials_changed',
+            model_name="user",
+            name="credentials_changed",
             field=models.DateTimeField(auto_now_add=True),
         ),
     ]

+ 19 - 11
api/desecapi/models/authenticated_actions.py

@@ -31,13 +31,14 @@ class AuthenticatedAction(models.Model):
     (3) when provided with data that allows instantiation and a valid state hash, take the defined action, possibly with
         additional parameters chosen by the third party that do not belong to the verified state.
     """
+
     _validated = False
 
     class Meta:
         managed = False
 
     def __init__(self, *args, **kwargs):
-        state = kwargs.pop('state', None)
+        state = kwargs.pop("state", None)
         super().__init__(*args, **kwargs)
 
         if state is not None:
@@ -65,7 +66,7 @@ class AuthenticatedAction(models.Model):
 
         :return: List of values to be signed.
         """
-        name = '.'.join([self.__module__, self.__class__.__qualname__])
+        name = ".".join([self.__module__, self.__class__.__qualname__])
         return [name]
 
     @staticmethod
@@ -91,7 +92,7 @@ class AuthenticatedAction(models.Model):
 
     def act(self):
         if not self._validated:
-            raise RuntimeError('Action state could not be verified.')
+            raise RuntimeError("Action state could not be verified.")
         return self._act()
 
 
@@ -99,7 +100,8 @@ class AuthenticatedBasicUserAction(AuthenticatedAction):
     """
     Abstract AuthenticatedAction involving a user instance.
     """
-    user = models.ForeignKey('User', on_delete=models.DO_NOTHING)
+
+    user = models.ForeignKey("User", on_delete=models.DO_NOTHING)
 
     class Meta:
         managed = False
@@ -142,13 +144,18 @@ class AuthenticatedUserAction(AuthenticatedBasicUserAction):
     Abstract AuthenticatedBasicUserAction, incorporating the user's id, email, password, and is_active flag into the
     Message Authentication Code state.
     """
+
     class Meta:
         managed = False
 
     def validate_legacy_state(self, value):
         # NEW: (1) classname, (2) user.id, (3) user.credentials_changed, (4) user.is_active, (7 ...) [subclasses]
         # OLD: (1) classname, (2) user.id, (3) user.email, (4) user.password, (5) user.is_active, (6 ...) [subclasses]
-        legacy_state_fields = self._state_fields[:2] + [self.user.email, self.user.password] + self._state_fields[3:]
+        legacy_state_fields = (
+            self._state_fields[:2]
+            + [self.user.email, self.user.password]
+            + self._state_fields[3:]
+        )
         return value == self.state_of(legacy_state_fields)
 
     def validate_state(self, value):
@@ -158,7 +165,10 @@ class AuthenticatedUserAction(AuthenticatedBasicUserAction):
 
     @property
     def _state_fields(self):
-        return super()._state_fields + [self.user.credentials_changed.isoformat(), self.user.is_active]
+        return super()._state_fields + [
+            self.user.credentials_changed.isoformat(),
+            self.user.is_active,
+        ]
 
 
 class AuthenticatedActivateUserAction(AuthenticatedUserAction):
@@ -190,7 +200,6 @@ class AuthenticatedChangeEmailUserAction(AuthenticatedUserAction):
 
 
 class AuthenticatedNoopUserAction(AuthenticatedUserAction):
-
     class Meta:
         managed = False
 
@@ -209,7 +218,6 @@ class AuthenticatedResetPasswordUserAction(AuthenticatedUserAction):
 
 
 class AuthenticatedDeleteUserAction(AuthenticatedUserAction):
-
     class Meta:
         managed = False
 
@@ -222,7 +230,8 @@ class AuthenticatedDomainBasicUserAction(AuthenticatedBasicUserAction):
     Abstract AuthenticatedBasicUserAction involving an domain instance, incorporating the domain's id, name as well as
     the owner ID into the Message Authentication Code state.
     """
-    domain = models.ForeignKey('Domain', on_delete=models.DO_NOTHING)
+
+    domain = models.ForeignKey("Domain", on_delete=models.DO_NOTHING)
 
     class Meta:
         managed = False
@@ -237,7 +246,6 @@ class AuthenticatedDomainBasicUserAction(AuthenticatedBasicUserAction):
 
 
 class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction):
-
     class Meta:
         managed = False
 
@@ -248,4 +256,4 @@ class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction
     def _act(self):
         self.domain.renewal_state = Domain.RenewalState.FRESH
         self.domain.renewal_changed = timezone.now()
-        self.domain.save(update_fields=['renewal_state', 'renewal_changed'])
+        self.domain.save(update_fields=["renewal_state", "renewal_changed"])

+ 16 - 12
api/desecapi/models/base.py

@@ -6,25 +6,29 @@ from django.core.validators import RegexValidator
 
 def validate_lower(value):
     if value != value.lower():
-        raise ValidationError('Invalid value (not lowercase): %(value)s',
-                              code='invalid',
-                              params={'value': value})
+        raise ValidationError(
+            "Invalid value (not lowercase): %(value)s",
+            code="invalid",
+            params={"value": value},
+        )
 
 
 def validate_upper(value):
     if value != value.upper():
-        raise ValidationError('Invalid value (not uppercase): %(value)s',
-                              code='invalid',
-                              params={'value': value})
+        raise ValidationError(
+            "Invalid value (not uppercase): %(value)s",
+            code="invalid",
+            params={"value": value},
+        )
 
 
 validate_domain_name = [
     validate_lower,
     RegexValidator(
-        regex=r'^(([a-z0-9_-]{1,63})\.)*[a-z0-9-]{1,63}$',
-        message='Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, '
-                'hyphens, and underscores. The last label may not contain an underscore.',
-        code='invalid_domain_name',
-        flags=re.IGNORECASE
-    )
+        regex=r"^(([a-z0-9_-]{1,63})\.)*[a-z0-9-]{1,63}$",
+        message="Domain names must be labels separated by dots. Labels may consist of up to 63 letters, digits, "
+        "hyphens, and underscores. The last label may not contain an underscore.",
+        code="invalid_domain_name",
+        flags=re.IGNORECASE,
+    ),
 ]

+ 10 - 10
api/desecapi/models/captcha.py

@@ -14,24 +14,25 @@ from desecapi import metrics
 
 def captcha_default_content(kind: str) -> str:
     if kind == Captcha.Kind.IMAGE:
-        alphabet = (string.ascii_uppercase + string.digits).translate({ord(c): None for c in 'IO0'})
+        alphabet = (string.ascii_uppercase + string.digits).translate(
+            {ord(c): None for c in "IO0"}
+        )
         length = 5
     elif kind == Captcha.Kind.AUDIO:
         alphabet = string.digits
         length = 8
     else:
-        raise ValueError(f'Unknown Captcha kind: {kind}')
+        raise ValueError(f"Unknown Captcha kind: {kind}")
 
-    content = ''.join([secrets.choice(alphabet) for _ in range(length)])
-    metrics.get('desecapi_captcha_content_created').labels(kind).inc()
+    content = "".join([secrets.choice(alphabet) for _ in range(length)])
+    metrics.get("desecapi_captcha_content_created").labels(kind).inc()
     return content
 
 
-class Captcha(ExportModelOperationsMixin('Captcha'), models.Model):
-
+class Captcha(ExportModelOperationsMixin("Captcha"), models.Model):
     class Kind(models.TextChoices):
-        IMAGE = 'image'
-        AUDIO = 'audio'
+        IMAGE = "image"
+        AUDIO = "audio"
 
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
@@ -48,6 +49,5 @@ class Captcha(ExportModelOperationsMixin('Captcha'), models.Model):
         self.delete()
         return (
             str(solution).upper().strip() == self.content  # solution correct
-            and
-            age <= settings.CAPTCHA_VALIDITY_PERIOD  # not expired
+            and age <= settings.CAPTCHA_VALIDITY_PERIOD  # not expired
         )

+ 116 - 54
api/desecapi/models/domains.py

@@ -21,23 +21,27 @@ from .base import validate_domain_name
 from .records import RRset
 
 
-psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER, timeout=.5)
+psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER, timeout=0.5)
 
 
 class DomainManager(Manager):
     def filter_qname(self, qname: str, **kwargs) -> models.query.QuerySet:
-        qs = self.annotate(name_length=Length('name'))  # callers expect this to be present after returning
+        qs = self.annotate(
+            name_length=Length("name")
+        )  # callers expect this to be present after returning
         try:
-            Domain._meta.get_field('name').run_validators(qname.removeprefix('*.').lower())
+            Domain._meta.get_field("name").run_validators(
+                qname.removeprefix("*.").lower()
+            )
         except ValidationError:
             return qs.none()
         return qs.annotate(
-            dotted_name=Concat(Value('.'), 'name', output_field=CharField()),
-            dotted_qname=Value(f'.{qname}', output_field=CharField()),
-        ).filter(dotted_qname__endswith=F('dotted_name'), **kwargs)
+            dotted_name=Concat(Value("."), "name", output_field=CharField()),
+            dotted_qname=Value(f".{qname}", output_field=CharField()),
+        ).filter(dotted_qname__endswith=F("dotted_name"), **kwargs)
 
 
-class Domain(ExportModelOperationsMixin('Domain'), models.Model):
+class Domain(ExportModelOperationsMixin("Domain"), models.Model):
     @staticmethod
     def _minimum_ttl_default():
         return settings.MINIMUM_TTL_DEFAULT
@@ -49,28 +53,36 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         WARNED = 3
 
     created = models.DateTimeField(auto_now_add=True)
-    name = models.CharField(max_length=191,
-                            unique=True,
-                            validators=validate_domain_name)
-    owner = models.ForeignKey('User', on_delete=models.PROTECT, related_name='domains')
+    name = models.CharField(
+        max_length=191, unique=True, validators=validate_domain_name
+    )
+    owner = models.ForeignKey("User", on_delete=models.PROTECT, related_name="domains")
     published = models.DateTimeField(null=True, blank=True)
     minimum_ttl = models.PositiveIntegerField(default=_minimum_ttl_default.__func__)
-    renewal_state = models.IntegerField(choices=RenewalState.choices, default=RenewalState.IMMORTAL)
+    renewal_state = models.IntegerField(
+        choices=RenewalState.choices, default=RenewalState.IMMORTAL
+    )
     renewal_changed = models.DateTimeField(auto_now_add=True)
 
     _keys = None
     objects = DomainManager()
 
     class Meta:
-        constraints = [models.UniqueConstraint(fields=['id', 'owner'], name='unique_id_owner')]
-        ordering = ('created',)
+        constraints = [
+            models.UniqueConstraint(fields=["id", "owner"], name="unique_id_owner")
+        ]
+        ordering = ("created",)
 
     def __init__(self, *args, **kwargs):
-        if isinstance(kwargs.get('owner'), AnonymousUser):
-            kwargs = {**kwargs, 'owner': None}  # make a copy and override
+        if isinstance(kwargs.get("owner"), AnonymousUser):
+            kwargs = {**kwargs, "owner": None}  # make a copy and override
         # Avoid super().__init__(owner=None, ...) to not mess up *values instantiation in django.db.models.Model.from_db
         super().__init__(*args, **kwargs)
-        if self.pk is None and kwargs.get('renewal_state') is None and self.is_locally_registrable:
+        if (
+            self.pk is None
+            and kwargs.get("renewal_state") is None
+            and self.is_locally_registrable
+        ):
             self.renewal_state = Domain.RenewalState.FRESH
 
     @cached_property
@@ -79,8 +91,8 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
             public_suffix = psl.get_public_suffix(self.name)
             is_public_suffix = psl.is_public_suffix(self.name)
         except (Timeout, NoNameservers):
-            public_suffix = self.name.rpartition('.')[2]
-            is_public_suffix = ('.' not in self.name)  # TLDs are public suffixes
+            public_suffix = self.name.rpartition(".")[2]
+            is_public_suffix = "." not in self.name  # TLDs are public suffixes
 
         if is_public_suffix:
             return public_suffix
@@ -88,8 +100,12 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         # Take into account that any of the parent domains could be a local public suffix. To that
         # end, identify the longest local public suffix that is actually a suffix of domain_name.
         for local_public_suffix in settings.LOCAL_PUBLIC_SUFFIXES:
-            has_local_public_suffix_parent = ('.' + self.name).endswith('.' + local_public_suffix)
-            if has_local_public_suffix_parent and len(local_public_suffix) > len(public_suffix):
+            has_local_public_suffix_parent = ("." + self.name).endswith(
+                "." + local_public_suffix
+            )
+            if has_local_public_suffix_parent and len(local_public_suffix) > len(
+                public_suffix
+            ):
                 public_suffix = local_public_suffix
 
         return public_suffix
@@ -98,20 +114,29 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         # Generate a list of all domains connecting this one and its public suffix.
         # If another user owns a zone with one of these names, then the requested
         # domain is unavailable because it is part of the other user's zone.
-        private_components = self.name.rsplit(self.public_suffix, 1)[0].rstrip('.')
-        private_components = private_components.split('.') if private_components else []
-        private_domains = ['.'.join(private_components[i:]) for i in range(0, len(private_components))]
-        private_domains = [f'{private_domain}.{self.public_suffix}' for private_domain in private_domains]
+        private_components = self.name.rsplit(self.public_suffix, 1)[0].rstrip(".")
+        private_components = private_components.split(".") if private_components else []
+        private_domains = [
+            ".".join(private_components[i:]) for i in range(0, len(private_components))
+        ]
+        private_domains = [
+            f"{private_domain}.{self.public_suffix}"
+            for private_domain in private_domains
+        ]
         assert self.name == next(iter(private_domains), self.public_suffix)
 
         # Determine whether domain is covered by other users' zones
-        return Domain.objects.filter(Q(name__in=private_domains) & ~Q(owner=self._owner_or_none)).exists()
+        return Domain.objects.filter(
+            Q(name__in=private_domains) & ~Q(owner=self._owner_or_none)
+        ).exists()
 
     def covers_foreign_zone(self):
         # Note: This is not completely accurate: Ideally, we should only consider zones with identical public suffix.
         # (If a public suffix lies in between, it's ok.) However, as there could be many descendant zones, the accurate
         # check is expensive, so currently not implemented (PSL lookups for each of them).
-        return Domain.objects.filter(Q(name__endswith=f'.{self.name}') & ~Q(owner=self._owner_or_none)).exists()
+        return Domain.objects.filter(
+            Q(name__endswith=f".{self.name}") & ~Q(owner=self._owner_or_none)
+        ).exists()
 
     def is_registrable(self):
         """
@@ -119,11 +144,11 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
         Otherwise, True is returned.
         """
         self.clean()  # ensure .name is a domain name
-        private_generation = self.name.count('.') - self.public_suffix.count('.')
+        private_generation = self.name.count(".") - self.public_suffix.count(".")
         assert private_generation >= 0
 
         # .internal is reserved
-        if f'.{self.name}'.endswith('.internal'):
+        if f".{self.name}".endswith(".internal"):
             return False
 
         # Public suffixes can only be registered if they are local
@@ -131,8 +156,14 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
             return False
 
         # Disallow _acme-challenge.dedyn.io and the like. Rejects reserved direct children of public suffixes.
-        reserved_prefixes = ('_', 'autoconfig.', 'autodiscover.',)
-        if private_generation == 1 and any(self.name.startswith(prefix) for prefix in reserved_prefixes):
+        reserved_prefixes = (
+            "_",
+            "autoconfig.",
+            "autodiscover.",
+        )
+        if private_generation == 1 and any(
+            self.name.startswith(prefix) for prefix in reserved_prefixes
+        ):
             return False
 
         # Domains covered by another user's zone can't be registered
@@ -148,29 +179,42 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     @property
     def keys(self):
         if not self._keys:
-            self._keys = [{**key, 'managed': True} for key in pdns.get_keys(self)]
+            self._keys = [{**key, "managed": True} for key in pdns.get_keys(self)]
             try:
-                unmanaged_keys = self.rrset_set.get(subname='', type='DNSKEY').records.all()
+                unmanaged_keys = self.rrset_set.get(
+                    subname="", type="DNSKEY"
+                ).records.all()
             except RRset.DoesNotExist:
                 pass
             else:
                 name = dns.name.from_text(self.name)
                 for rr in unmanaged_keys:
-                    key = dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.DNSKEY, rr.content)
+                    key = dns.rdata.from_text(
+                        dns.rdataclass.IN, dns.rdatatype.DNSKEY, rr.content
+                    )
                     key_is_sep = key.flags & dns.rdtypes.ANY.DNSKEY.SEP
-                    self._keys.append({
-                        'dnskey': rr.content,
-                        'ds': [dns.dnssec.make_ds(name, key, algo).to_text() for algo in (2, 4)] if key_is_sep else [],
-                        'flags': key.flags,  # deprecated
-                        'keytype': None,  # deprecated
-                        'managed': False,
-                    })
+                    self._keys.append(
+                        {
+                            "dnskey": rr.content,
+                            "ds": [
+                                dns.dnssec.make_ds(name, key, algo).to_text()
+                                for algo in (2, 4)
+                            ]
+                            if key_is_sep
+                            else [],
+                            "flags": key.flags,  # deprecated
+                            "keytype": None,  # deprecated
+                            "managed": False,
+                        }
+                    )
         return self._keys
 
     @property
     def touched(self):
         try:
-            rrset_touched = max(updated for updated in self.rrset_set.values_list('touched', flat=True))
+            rrset_touched = max(
+                updated for updated in self.rrset_set.values_list("touched", flat=True)
+            )
         except ValueError:  # no RRsets (but there should be at least NS)
             return self.published  # may be None if the domain was never published
         return max(rrset_touched, self.published or rrset_touched)
@@ -192,7 +236,7 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
 
     @property
     def _partitioned_name(self):
-        subname, _, parent_name = self.name.partition('.')
+        subname, _, parent_name = self.name.partition(".")
         return subname, parent_name or None
 
     def save(self, *args, **kwargs):
@@ -202,30 +246,48 @@ class Domain(ExportModelOperationsMixin('Domain'), models.Model):
     def update_delegation(self, child_domain: Domain):
         child_subname, child_domain_name = child_domain._partitioned_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))
+            raise ValueError(
+                "Cannot update delegation of %s as it is not an immediate child domain of %s."
+                % (child_domain.name, self.name)
+            )
 
         # Always remove delegation so that we con properly recreate it
-        for rrset in self.rrset_set.filter(subname=child_subname, type__in=['NS', 'DS']):
+        for rrset in self.rrset_set.filter(
+            subname=child_subname, type__in=["NS", "DS"]
+        ):
             rrset.delete()
 
         if child_domain.pk:
             # Domain real: (re-)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']])
-            metrics.get('desecapi_autodelegation_created').inc()
+                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"]],
+            )
+            metrics.get("desecapi_autodelegation_created").inc()
         else:
             # Domain not real: that's it
-            metrics.get('desecapi_autodelegation_deleted').inc()
+            metrics.get("desecapi_autodelegation_deleted").inc()
 
     def delete(self):
         ret = super().delete()
-        logger.warning(f'Domain {self.name} deleted (owner: {self.owner.pk})')
+        logger.warning(f"Domain {self.name} deleted (owner: {self.owner.pk})")
         return ret
 
     def __str__(self):

+ 1 - 1
api/desecapi/models/donation.py

@@ -8,7 +8,7 @@ from django.utils import timezone
 from django_prometheus.models import ExportModelOperationsMixin
 
 
-class Donation(ExportModelOperationsMixin('Donation'), models.Model):
+class Donation(ExportModelOperationsMixin("Donation"), models.Model):
     @staticmethod
     def _created_default():
         return timezone.now()

+ 91 - 52
api/desecapi/models/records.py

@@ -24,26 +24,35 @@ from .base import validate_lower, validate_upper
 # RR set types: the good, the bad, and the ugly
 # known, but unsupported types
 RR_SET_TYPES_UNSUPPORTED = {
-    'ALIAS',  # Requires signing at the frontend, hence unsupported in desec-stack
-    'IPSECKEY',  # broken in pdns, https://github.com/PowerDNS/pdns/issues/10589 TODO enable with pdns auth >= 4.7.0
-    'KEY',  # Application use restricted by RFC 3445, DNSSEC use replaced by DNSKEY and handled automatically
-    'WKS',  # General usage not recommended, "SHOULD NOT" be used in SMTP (RFC 1123)
+    "ALIAS",  # Requires signing at the frontend, hence unsupported in desec-stack
+    "IPSECKEY",  # broken in pdns, https://github.com/PowerDNS/pdns/issues/10589 TODO enable with pdns auth >= 4.7.0
+    "KEY",  # Application use restricted by RFC 3445, DNSSEC use replaced by DNSKEY and handled automatically
+    "WKS",  # General usage not recommended, "SHOULD NOT" be used in SMTP (RFC 1123)
 }
 # restricted types are managed in use by the API, and cannot directly be modified by the API client
 RR_SET_TYPES_AUTOMATIC = {
     # corresponding functionality is automatically managed:
-    'KEY', 'NSEC', 'NSEC3', 'OPT', 'RRSIG',
+    "KEY",
+    "NSEC",
+    "NSEC3",
+    "OPT",
+    "RRSIG",
     # automatically managed by the API:
-    'NSEC3PARAM', 'SOA'
+    "NSEC3PARAM",
+    "SOA",
 }
 # backend types are types that are the types supported by the backend(s)
 RR_SET_TYPES_BACKEND = pdns.SUPPORTED_RRSET_TYPES
 # validation types are types supported by the validation backend, currently: dnspython
-RR_SET_TYPES_VALIDATION = set(ANY.__all__) | set(IN.__all__) \
-                          | {'L32', 'L64', 'LP', 'NID'}  # https://github.com/rthalley/dnspython/pull/751
+RR_SET_TYPES_VALIDATION = (
+    set(ANY.__all__) | set(IN.__all__) | {"L32", "L64", "LP", "NID"}
+)  # https://github.com/rthalley/dnspython/pull/751
 # manageable types are directly managed by the API client
-RR_SET_TYPES_MANAGEABLE = \
-        (RR_SET_TYPES_BACKEND & RR_SET_TYPES_VALIDATION) - RR_SET_TYPES_UNSUPPORTED - RR_SET_TYPES_AUTOMATIC
+RR_SET_TYPES_MANAGEABLE = (
+    (RR_SET_TYPES_BACKEND & RR_SET_TYPES_VALIDATION)
+    - RR_SET_TYPES_UNSUPPORTED
+    - RR_SET_TYPES_AUTOMATIC
+)
 
 
 class RRsetManager(Manager):
@@ -54,34 +63,34 @@ class RRsetManager(Manager):
         return rrset
 
 
-class RRset(ExportModelOperationsMixin('RRset'), models.Model):
+class RRset(ExportModelOperationsMixin("RRset"), models.Model):
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
     touched = models.DateTimeField(auto_now=True, db_index=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,
             validators.RegexValidator(
-                regex=r'^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$',
-                message='Subname can only use (lowercase) a-z, 0-9, ., -, and _, '
-                        'may start with a \'*.\', or just be \'*\'. Components may not exceed 63 characters.',
-                code='invalid_subname'
-            )
-        ]
+                regex=r"^([*]|(([*][.])?([a-z0-9_-]{1,63}[.])*[a-z0-9_-]{1,63}))$",
+                message="Subname can only use (lowercase) a-z, 0-9, ., -, and _, "
+                "may start with a '*.', or just be '*'. Components may not exceed 63 characters.",
+                code="invalid_subname",
+            ),
+        ],
     )
     type = models.CharField(
         max_length=10,
         validators=[
             validate_upper,
             validators.RegexValidator(
-                regex=r'^[A-Z][A-Z0-9]*$',
-                message='Type must be uppercase alphanumeric and start with a letter.',
-                code='invalid_type'
-            )
-        ]
+                regex=r"^[A-Z][A-Z0-9]*$",
+                message="Type must be uppercase alphanumeric and start with a letter.",
+                code="invalid_type",
+            ),
+        ],
     )
     ttl = models.PositiveIntegerField()
 
@@ -90,10 +99,10 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
     class Meta:
         constraints = [
             ExclusionConstraint(
-                name='cname_exclusivity',
+                name="cname_exclusivity",
                 expressions=[
-                    ('domain', RangeOperators.EQUAL),
-                    ('subname', RangeOperators.EQUAL),
+                    ("domain", RangeOperators.EQUAL),
+                    ("subname", RangeOperators.EQUAL),
                     (RawSQL("int4(type = 'CNAME')", ()), RangeOperators.NOT_EQUAL),
                 ],
             ),
@@ -102,7 +111,7 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
 
     @staticmethod
     def construct_name(subname, domain_name):
-        return '.'.join(filter(None, [subname, domain_name])) + '.'
+        return ".".join(filter(None, [subname, domain_name])) + "."
 
     @property
     def name(self):
@@ -127,21 +136,27 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
         errors = []
 
         # Singletons
-        if self.type in ('CNAME', 'DNAME',):
+        if self.type in (
+            "CNAME",
+            "DNAME",
+        ):
             if len(records_presentation_format) > 1:
-                errors.append(f'{self.type} RRset cannot have multiple records.')
+                errors.append(f"{self.type} RRset cannot have multiple records.")
 
         # Non-apex
-        if self.type in ('CNAME', 'DS',):
-            if self.subname == '':
-                errors.append(f'{self.type} RRset cannot have empty subname.')
+        if self.type in (
+            "CNAME",
+            "DS",
+        ):
+            if self.subname == "":
+                errors.append(f"{self.type} RRset cannot have empty subname.")
 
-        if self.type in ('DNSKEY',):
-            if self.subname != '':
-                errors.append(f'{self.type} RRset must have empty subname.')
+        if self.type in ("DNSKEY",):
+            if self.subname != "":
+                errors.append(f"{self.type} RRset must have empty subname.")
 
         def _error_msg(record, detail):
-            return f'Record content of {self.type} {self.name} invalid: \'{record}\': {detail}'
+            return f"Record content of {self.type} {self.name} invalid: '{record}': {detail}"
 
         records_canonical_format = set()
         for r in records_presentation_format:
@@ -151,8 +166,13 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
                 errors.append(_error_msg(r, str(ex)))
             else:
                 if r_canonical_format in records_canonical_format:
-                    errors.append(_error_msg(r, f'Duplicate record content: this is identical to '
-                                                f'\'{r_canonical_format}\''))
+                    errors.append(
+                        _error_msg(
+                            r,
+                            f"Duplicate record content: this is identical to "
+                            f"'{r_canonical_format}'",
+                        )
+                    )
                 else:
                     records_canonical_format.add(r_canonical_format)
 
@@ -192,7 +212,12 @@ class RRset(ExportModelOperationsMixin('RRset'), models.Model):
         RR.objects.bulk_create(rrs)  # One INSERT
 
     def __str__(self):
-        return '<RRSet %s domain=%s type=%s subname=%s>' % (self.pk, self.domain.name, self.type, self.subname)
+        return "<RRSet %s domain=%s type=%s subname=%s>" % (
+            self.pk,
+            self.domain.name,
+            self.type,
+            self.subname,
+        )
 
 
 class RRManager(Manager):
@@ -207,9 +232,9 @@ class RRManager(Manager):
         return ret
 
 
-class RR(ExportModelOperationsMixin('RR'), models.Model):
+class RR(ExportModelOperationsMixin("RR"), models.Model):
     created = models.DateTimeField(auto_now_add=True)
-    rrset = models.ForeignKey(RRset, on_delete=models.CASCADE, related_name='records')
+    rrset = models.ForeignKey(RRset, on_delete=models.CASCADE, related_name="records")
     content = models.TextField()
 
     objects = RRManager()
@@ -239,40 +264,54 @@ class RR(ExportModelOperationsMixin('RR'), models.Model):
                 rdclass=rdataclass.IN,
                 rdtype=rdtype,
                 tok=dns.tokenizer.Tokenizer(any_presentation_format),
-                relativize=False
+                relativize=False,
             ).to_digestable()
 
             if len(wire) > 64000:
-                raise ValidationError(f'Ensure this value has no more than 64000 byte in wire format (it has {len(wire)}).')
+                raise ValidationError(
+                    f"Ensure this value has no more than 64000 byte in wire format (it has {len(wire)})."
+                )
 
             parser = dns.wire.Parser(wire, current=0)
             with parser.restrict_to(len(wire)):
-                rdata = cls.from_wire_parser(rdclass=rdataclass.IN, rdtype=rdtype, parser=parser)
+                rdata = cls.from_wire_parser(
+                    rdclass=rdataclass.IN, rdtype=rdtype, parser=parser
+                )
 
             # Convert to canonical presentation format, disable chunking of records.
             # Exempt types which have chunksize hardcoded (prevents "got multiple values for keyword argument 'chunksize'").
-            chunksize_exception_types = (dns.rdatatype.OPENPGPKEY, dns.rdatatype.EUI48, dns.rdatatype.EUI64)
+            chunksize_exception_types = (
+                dns.rdatatype.OPENPGPKEY,
+                dns.rdatatype.EUI48,
+                dns.rdatatype.EUI64,
+            )
             if rdtype in chunksize_exception_types:
                 return rdata.to_text()
             else:
                 return rdata.to_text(chunksize=0)
         except binascii.Error:
             # e.g., odd-length string
-            raise ValueError('Cannot parse hexadecimal or base64 record contents')
+            raise ValueError("Cannot parse hexadecimal or base64 record contents")
         except dns.exception.SyntaxError as e:
             # e.g., A/127.0.0.999
-            if 'quote' in e.args[0]:
-                raise ValueError(f'Data for {type_} records must be given using quotation marks.')
+            if "quote" in e.args[0]:
+                raise ValueError(
+                    f"Data for {type_} records must be given using quotation marks."
+                )
             else:
-                raise ValueError(f'Record content for type {type_} malformed: {",".join(e.args)}')
+                raise ValueError(
+                    f'Record content for type {type_} malformed: {",".join(e.args)}'
+                )
         except dns.name.NeedAbsoluteNameOrOrigin:
-            raise ValueError('Hostname must be fully qualified (i.e., end in a dot: "example.com.")')
+            raise ValueError(
+                'Hostname must be fully qualified (i.e., end in a dot: "example.com.")'
+            )
         except ValueError as ex:
             # e.g., string ("asdf") cannot be parsed into int on base 10
-            raise ValueError(f'Cannot parse record contents: {ex}')
+            raise ValueError(f"Cannot parse record contents: {ex}")
         except Exception as e:
             # TODO see what exceptions raise here for faulty input
             raise e
 
     def __str__(self):
-        return '<RR %s %s rr_set=%s>' % (self.pk, self.content, self.rrset.pk)
+        return "<RR %s %s rr_set=%s>" % (self.pk, self.content, self.rrset.pk)

+ 65 - 31
api/desecapi/models/tokens.py

@@ -18,29 +18,38 @@ from django_prometheus.models import ExportModelOperationsMixin
 from netfields import CidrAddressField, NetManager
 
 
-class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models.Token):
+class Token(ExportModelOperationsMixin("Token"), rest_framework.authtoken.models.Token):
     @staticmethod
     def _allowed_subnets_default():
-        return [ipaddress.IPv4Network('0.0.0.0/0'), ipaddress.IPv6Network('::/0')]
+        return [ipaddress.IPv4Network("0.0.0.0/0"), ipaddress.IPv6Network("::/0")]
 
-    _validators = [validators.MinValueValidator(timedelta(0)), validators.MaxValueValidator(timedelta(days=365*1000))]
+    _validators = [
+        validators.MinValueValidator(timedelta(0)),
+        validators.MaxValueValidator(timedelta(days=365 * 1000)),
+    ]
 
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     key = models.CharField("Key", max_length=128, db_index=True, unique=True)
-    user = models.ForeignKey('User', on_delete=models.CASCADE)
-    name = models.CharField('Name', blank=True, max_length=64)
+    user = models.ForeignKey("User", on_delete=models.CASCADE)
+    name = models.CharField("Name", blank=True, max_length=64)
     last_used = models.DateTimeField(null=True, blank=True)
     perm_manage_tokens = models.BooleanField(default=False)
-    allowed_subnets = ArrayField(CidrAddressField(), default=_allowed_subnets_default.__func__)
+    allowed_subnets = ArrayField(
+        CidrAddressField(), default=_allowed_subnets_default.__func__
+    )
     max_age = models.DurationField(null=True, default=None, validators=_validators)
-    max_unused_period = models.DurationField(null=True, default=None, validators=_validators)
-    domain_policies = models.ManyToManyField('Domain', through='TokenDomainPolicy')
+    max_unused_period = models.DurationField(
+        null=True, default=None, validators=_validators
+    )
+    domain_policies = models.ManyToManyField("Domain", through="TokenDomainPolicy")
 
     plain = None
     objects = NetManager()
 
     class Meta:
-        constraints = [models.UniqueConstraint(fields=['id', 'user'], name='unique_id_user')]
+        constraints = [
+            models.UniqueConstraint(fields=["id", "user"], name="unique_id_user")
+        ]
 
     @property
     def is_valid(self):
@@ -69,11 +78,17 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
 
     @staticmethod
     def make_hash(plain):
-        return make_password(plain, salt='static', hasher='pbkdf2_sha256_iter1')
+        return make_password(plain, salt="static", hasher="pbkdf2_sha256_iter1")
 
     def get_policy(self, *, domain=None):
-        order_by = F('domain').asc(nulls_last=True)  # default Postgres sorting, but: explicit is better than implicit
-        return self.tokendomainpolicy_set.filter(Q(domain=domain) | Q(domain__isnull=True)).order_by(order_by).first()
+        order_by = F("domain").asc(
+            nulls_last=True
+        )  # default Postgres sorting, but: explicit is better than implicit
+        return (
+            self.tokendomainpolicy_set.filter(Q(domain=domain) | Q(domain__isnull=True))
+            .order_by(order_by)
+            .first()
+        )
 
     @transaction.atomic
     def delete(self):
@@ -88,51 +103,56 @@ class Token(ExportModelOperationsMixin('Token'), rest_framework.authtoken.models
 @pgtrigger.register(
     # Ensure that token_user is consistent with token
     pgtrigger.Trigger(
-        name='token_user',
+        name="token_user",
         operation=pgtrigger.Update | pgtrigger.Insert,
         when=pgtrigger.Before,
-        func='NEW.token_user_id = (SELECT user_id FROM desecapi_token WHERE id = NEW.token_id); RETURN NEW;',
+        func="NEW.token_user_id = (SELECT user_id FROM desecapi_token WHERE id = NEW.token_id); RETURN NEW;",
     ),
-
     # Ensure that if there is *any* domain policy for a given token, there is always one with domain=None.
     pgtrigger.Trigger(
-        name='default_policy_on_insert',
+        name="default_policy_on_insert",
         operation=pgtrigger.Insert,
         when=pgtrigger.Before,
         # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
         func="IF (NEW.domain_id IS NOT NULL and NOT EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NULL AND token_id = NEW.token_id)) THEN "
-             "  RAISE EXCEPTION 'Cannot insert non-default policy into % table when default policy is not present', TG_TABLE_NAME; "
-             "END IF; RETURN NEW;",
+        "  RAISE EXCEPTION 'Cannot insert non-default policy into % table when default policy is not present', TG_TABLE_NAME; "
+        "END IF; RETURN NEW;",
     ),
     pgtrigger.Protect(
-        name='default_policy_on_update',
+        name="default_policy_on_update",
         operation=pgtrigger.Update,
         when=pgtrigger.Before,
         condition=pgtrigger.Q(old__domain__isnull=True, new__domain__isnull=False),
     ),
     # Ideally, a deferred trigger (https://github.com/Opus10/django-pgtrigger/issues/14). Available in 3.4.0.
     pgtrigger.Trigger(
-        name='default_policy_on_delete',
+        name="default_policy_on_delete",
         operation=pgtrigger.Delete,
         when=pgtrigger.Before,
         # Trigger `condition` arguments (corresponding to WHEN clause) don't support subqueries, so we use `func`
         func="IF (OLD.domain_id IS NULL and EXISTS(SELECT * FROM desecapi_tokendomainpolicy WHERE domain_id IS NOT NULL AND token_id = OLD.token_id)) THEN "
-             "  RAISE EXCEPTION 'Cannot delete default policy from % table when non-default policy is present', TG_TABLE_NAME; "
-             "END IF; RETURN OLD;",
+        "  RAISE EXCEPTION 'Cannot delete default policy from % table when non-default policy is present', TG_TABLE_NAME; "
+        "END IF; RETURN OLD;",
     ),
 )
-class TokenDomainPolicy(ExportModelOperationsMixin('TokenDomainPolicy'), models.Model):
+class TokenDomainPolicy(ExportModelOperationsMixin("TokenDomainPolicy"), models.Model):
     token = models.ForeignKey(Token, on_delete=models.CASCADE)
-    domain = models.ForeignKey('Domain', on_delete=models.CASCADE, null=True)
+    domain = models.ForeignKey("Domain", on_delete=models.CASCADE, null=True)
     perm_dyndns = models.BooleanField(default=False)
     perm_rrsets = models.BooleanField(default=False)
     # Token user, filled via trigger. Used by compound FK constraints to tie domain.owner to token.user (see migration).
-    token_user = models.ForeignKey('User', on_delete=models.CASCADE, db_constraint=False, related_name='+')
+    token_user = models.ForeignKey(
+        "User", on_delete=models.CASCADE, db_constraint=False, related_name="+"
+    )
 
     class Meta:
         constraints = [
-            models.UniqueConstraint(fields=['token', 'domain'], name='unique_entry'),
-            models.UniqueConstraint(fields=['token'], condition=Q(domain__isnull=True), name='unique_entry_null_domain')
+            models.UniqueConstraint(fields=["token", "domain"], name="unique_entry"),
+            models.UniqueConstraint(
+                fields=["token"],
+                condition=Q(domain__isnull=True),
+                name="unique_entry_null_domain",
+            ),
         ]
 
     def clean(self):
@@ -140,16 +160,30 @@ class TokenDomainPolicy(ExportModelOperationsMixin('TokenDomainPolicy'), models.
         if self.pk:  # update
             # Can't change policy's default status ("domain NULLness") to maintain policy precedence
             if (self.domain is None) != (self.pk == default_policy.pk):
-                raise ValidationError({'domain': 'Policy precedence: Cannot disable default policy when others exist.'})
+                raise ValidationError(
+                    {
+                        "domain": "Policy precedence: Cannot disable default policy when others exist."
+                    }
+                )
         else:  # create
             # Can't violate policy precedence (default policy has to be first)
             if (self.domain is not None) and (default_policy is None):
-                raise ValidationError({'domain': 'Policy precedence: The first policy must be the default policy.'})
+                raise ValidationError(
+                    {
+                        "domain": "Policy precedence: The first policy must be the default policy."
+                    }
+                )
 
     def delete(self):
         # Can't delete default policy when others exist
-        if (self.domain is None) and self.token.tokendomainpolicy_set.exclude(domain__isnull=True).exists():
-            raise ValidationError({'domain': "Policy precedence: Can't delete default policy when there exist others."})
+        if (self.domain is None) and self.token.tokendomainpolicy_set.exclude(
+            domain__isnull=True
+        ).exists():
+            raise ValidationError(
+                {
+                    "domain": "Policy precedence: Can't delete default policy when there exist others."
+                }
+            )
         return super().delete()
 
     def save(self, *args, **kwargs):

+ 44 - 30
api/desecapi/models/users.py

@@ -20,7 +20,7 @@ class MyUserManager(BaseUserManager):
         Creates and saves a User with the given email and password.
         """
         if not email:
-            raise ValueError('Users must have an email address')
+            raise ValueError("Users must have an email address")
 
         email = self.normalize_email(email)
         user = self.model(email=email, **extra_fields)
@@ -29,14 +29,14 @@ class MyUserManager(BaseUserManager):
         return user
 
 
-class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
+class User(ExportModelOperationsMixin("User"), AbstractBaseUser):
     @staticmethod
     def _limit_domains_default():
         return settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT
 
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     email = CIEmailField(
-        verbose_name='email address',
+        verbose_name="email address",
         unique=True,
     )
     email_verified = models.DateTimeField(null=True, blank=True)
@@ -44,13 +44,15 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
     is_admin = models.BooleanField(default=False)
     created = models.DateTimeField(auto_now_add=True)
     credentials_changed = models.DateTimeField(auto_now_add=True)
-    limit_domains = models.PositiveIntegerField(default=_limit_domains_default.__func__, null=True, blank=True)
+    limit_domains = models.PositiveIntegerField(
+        default=_limit_domains_default.__func__, null=True, blank=True
+    )
     needs_captcha = models.BooleanField(default=True)
     outreach_preference = models.BooleanField(default=True)
 
     objects = MyUserManager()
 
-    USERNAME_FIELD = 'email'
+    USERNAME_FIELD = "email"
     REQUIRED_FIELDS = []
 
     def get_full_name(self):
@@ -92,51 +94,63 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
         self.validate_unique()
         self.save()
 
-        self.send_email('change-email-confirmation-old-email', recipient=old_email)
+        self.send_email("change-email-confirmation-old-email", recipient=old_email)
 
     def change_password(self, raw_password):
         self.set_password(raw_password)
         self.credentials_changed = timezone.now()
         self.save()
-        self.send_email('password-change-confirmation')
+        self.send_email("password-change-confirmation")
 
     def delete(self):
         pk = self.pk
         ret = super().delete()
-        logger.warning(f'User {pk} deleted')
+        logger.warning(f"User {pk} deleted")
         return ret
 
-    def send_email(self, reason, context=None, recipient=None, subject=None, template=None):
-        fast_lane = 'email_fast_lane'
-        slow_lane = 'email_slow_lane'
-        immediate_lane = 'email_immediate_lane'
+    def send_email(
+        self, reason, context=None, recipient=None, subject=None, template=None
+    ):
+        fast_lane = "email_fast_lane"
+        slow_lane = "email_slow_lane"
+        immediate_lane = "email_immediate_lane"
         lanes = {
-            'activate-account': slow_lane,
-            'change-email': slow_lane,
-            'change-email-confirmation-old-email': fast_lane,
-            'change-outreach-preference': slow_lane,
-            'confirm-account': slow_lane,
-            'password-change-confirmation': fast_lane,
-            'reset-password': fast_lane,
-            'delete-account': fast_lane,
-            'domain-dyndns': fast_lane,
-            'renew-domain': immediate_lane,
+            "activate-account": slow_lane,
+            "change-email": slow_lane,
+            "change-email-confirmation-old-email": fast_lane,
+            "change-outreach-preference": slow_lane,
+            "confirm-account": slow_lane,
+            "password-change-confirmation": fast_lane,
+            "reset-password": fast_lane,
+            "delete-account": fast_lane,
+            "domain-dyndns": fast_lane,
+            "renew-domain": immediate_lane,
         }
         if reason not in lanes:
-            raise ValueError(f'Cannot send email to user {self.pk} without a good reason: {reason}')
+            raise ValueError(
+                f"Cannot send email to user {self.pk} without a good reason: {reason}"
+            )
 
         context = context or {}
-        template = template or get_template(f'emails/{reason}/content.txt')
+        template = template or get_template(f"emails/{reason}/content.txt")
         content = template.render(context)
-        content += f'\nSupport Reference: user_id = {self.pk}\n'
+        content += f"\nSupport Reference: user_id = {self.pk}\n"
 
-        logger.warning(f'Queuing email for user account {self.pk} (reason: {reason}, lane: {lanes[reason]})')
+        logger.warning(
+            f"Queuing email for user account {self.pk} (reason: {reason}, lane: {lanes[reason]})"
+        )
         num_queued = EmailMessage(
-            subject=(subject or get_template(f'emails/{reason}/subject.txt').render(context)).strip(),
+            subject=(
+                subject or get_template(f"emails/{reason}/subject.txt").render(context)
+            ).strip(),
             body=content,
-            from_email=get_template('emails/from.txt').render(),
+            from_email=get_template("emails/from.txt").render(),
             to=[recipient or self.email],
-            connection=get_connection(lane=lanes[reason], debug={'user': self.pk, 'reason': reason})
+            connection=get_connection(
+                lane=lanes[reason], debug={"user": self.pk, "reason": reason}
+            ),
         ).send()
-        metrics.get('desecapi_messages_queued').labels(reason, self.pk, lanes[reason]).observe(num_queued)
+        metrics.get("desecapi_messages_queued").labels(
+            reason, self.pk, lanes[reason]
+        ).observe(num_queued)
         return num_queued

+ 12 - 5
api/desecapi/pagination.py

@@ -10,12 +10,17 @@ class LinkHeaderCursorPagination(CursorPagination):
     described in https://developer.github.com/v3/guides/traversing-with-pagination/
     Inspired by the django-rest-framework-link-header-pagination package.
     """
+
     template = None
 
     @staticmethod
     def construct_headers(pagination_map):
-        links = [f'<{url}>; rel="{label}"' for label, url in pagination_map.items() if url is not None]
-        return {'Link': ', '.join(links)} if links else {}
+        links = [
+            f'<{url}>; rel="{label}"'
+            for label, url in pagination_map.items()
+            if url is not None
+        ]
+        return {"Link": ", ".join(links)} if links else {}
 
     def get_paginated_response(self, data):
         pagination_required = self.has_next or self.has_previous
@@ -23,13 +28,15 @@ class LinkHeaderCursorPagination(CursorPagination):
             return Response(data)
 
         url = self.request.build_absolute_uri()
-        pagination_map = {'first': replace_query_param(url, self.cursor_query_param, '')}
+        pagination_map = {
+            "first": replace_query_param(url, self.cursor_query_param, "")
+        }
 
         if self.cursor_query_param not in self.request.query_params:
             count = self.queryset.count()
             data = {
-                'detail': f'Pagination required. You can query up to {self.page_size} items at a time ({count} total). '
-                          'Please use the `first` page link (see Link header).',
+                "detail": f"Pagination required. You can query up to {self.page_size} items at a time ({count} total). "
+                "Please use the `first` page link (see Link header).",
             }
             headers = self.construct_headers(pagination_map)
             return Response(data, headers=headers, status=status.HTTP_400_BAD_REQUEST)

+ 116 - 55
api/desecapi/pdns.py

@@ -12,15 +12,59 @@ from desecapi.exceptions import PDNSException, RequestEntityTooLarge
 SUPPORTED_RRSET_TYPES = {
     # https://doc.powerdns.com/authoritative/appendices/types.html
     # "major" types
-    'A', 'AAAA', 'AFSDB', 'ALIAS', 'APL', 'CAA', 'CERT', 'CDNSKEY', 'CDS', 'CNAME', 'CSYNC', 'DNSKEY', 'DNAME', 'DS',
-    'HINFO', 'HTTPS', 'KEY', 'LOC', 'MX', 'NAPTR', 'NS', 'NSEC', 'NSEC3', 'NSEC3PARAM', 'OPENPGPKEY', 'PTR', 'RP',
-    'RRSIG', 'SOA', 'SPF', 'SSHFP', 'SRV', 'SVCB', 'TLSA', 'SMIMEA', 'TXT', 'URI',
-
+    "A",
+    "AAAA",
+    "AFSDB",
+    "ALIAS",
+    "APL",
+    "CAA",
+    "CERT",
+    "CDNSKEY",
+    "CDS",
+    "CNAME",
+    "CSYNC",
+    "DNSKEY",
+    "DNAME",
+    "DS",
+    "HINFO",
+    "HTTPS",
+    "KEY",
+    "LOC",
+    "MX",
+    "NAPTR",
+    "NS",
+    "NSEC",
+    "NSEC3",
+    "NSEC3PARAM",
+    "OPENPGPKEY",
+    "PTR",
+    "RP",
+    "RRSIG",
+    "SOA",
+    "SPF",
+    "SSHFP",
+    "SRV",
+    "SVCB",
+    "TLSA",
+    "SMIMEA",
+    "TXT",
+    "URI",
     # "additional" types, without obsolete ones
-    'DHCID', 'DLV', 'EUI48', 'EUI64', 'IPSECKEY', 'KX', 'MINFO', 'MR', 'RKEY', 'WKS',
-
+    "DHCID",
+    "DLV",
+    "EUI48",
+    "EUI64",
+    "IPSECKEY",
+    "KX",
+    "MINFO",
+    "MR",
+    "RKEY",
+    "WKS",
     # https://doc.powerdns.com/authoritative/changelog/4.5.html#change-4.5.0-alpha1-New-Features
-    'NID', 'L32', 'L64', 'LP'
+    "NID",
+    "L32",
+    "L64",
+    "LP",
 }
 
 NSLORD = object()
@@ -28,22 +72,21 @@ NSMASTER = object()
 
 _config = {
     NSLORD: {
-        'base_url': settings.NSLORD_PDNS_API,
-        'headers': {
-            'Accept': 'application/json',
-            'User-Agent': 'desecapi',
-            'X-API-Key': settings.NSLORD_PDNS_API_TOKEN,
-        }
+        "base_url": settings.NSLORD_PDNS_API,
+        "headers": {
+            "Accept": "application/json",
+            "User-Agent": "desecapi",
+            "X-API-Key": settings.NSLORD_PDNS_API_TOKEN,
+        },
     },
     NSMASTER: {
-        'base_url': settings.NSMASTER_PDNS_API,
-        'headers': {
-            'Accept': 'application/json',
-            'User-Agent': 'desecapi',
-            'X-API-Key': settings.NSMASTER_PDNS_API_TOKEN,
-        }
-    }
-
+        "base_url": settings.NSMASTER_PDNS_API,
+        "headers": {
+            "Accept": "application/json",
+            "User-Agent": "desecapi",
+            "X-API-Key": settings.NSMASTER_PDNS_API_TOKEN,
+        },
+    },
 }
 
 
@@ -53,62 +96,71 @@ def _pdns_request(method, *, server, path, data=None):
     if data is not None and len(data) > settings.PDNS_MAX_BODY_SIZE:
         raise RequestEntityTooLarge
 
-    r = requests.request(method, _config[server]['base_url'] + path, data=data, headers=_config[server]['headers'])
+    r = requests.request(
+        method,
+        _config[server]["base_url"] + path,
+        data=data,
+        headers=_config[server]["headers"],
+    )
     if r.status_code not in range(200, 300):
         raise PDNSException(response=r)
-    metrics.get('desecapi_pdns_request_success').labels(method, r.status_code).inc()
+    metrics.get("desecapi_pdns_request_success").labels(method, r.status_code).inc()
     return r
 
 
 def _pdns_post(server, path, data):
-    return _pdns_request('post', server=server, path=path, data=data)
+    return _pdns_request("post", server=server, path=path, data=data)
 
 
 def _pdns_patch(server, path, data):
-    return _pdns_request('patch', server=server, path=path, data=data)
+    return _pdns_request("patch", server=server, path=path, data=data)
 
 
 def _pdns_get(server, path):
-    return _pdns_request('get', server=server, path=path)
+    return _pdns_request("get", server=server, path=path)
 
 
 def _pdns_put(server, path):
-    return _pdns_request('put', server=server, path=path)
+    return _pdns_request("put", server=server, path=path)
 
 
 def _pdns_delete(server, path):
-    return _pdns_request('delete', server=server, path=path)
+    return _pdns_request("delete", server=server, path=path)
 
 
 def pdns_id(name):
     # See also pdns code, apiZoneNameToId() in ws-api.cc (with the exception of forward slash)
-    if not re.match(r'^[a-zA-Z0-9_.-]+$', name):
-        raise SuspiciousOperation('Invalid hostname ' + name)
+    if not re.match(r"^[a-zA-Z0-9_.-]+$", name):
+        raise SuspiciousOperation("Invalid hostname " + name)
 
-    name = name.translate(str.maketrans({'/': '=2F', '_': '=5F'}))
-    return name.rstrip('.') + '.'
+    name = name.translate(str.maketrans({"/": "=2F", "_": "=5F"}))
+    return name.rstrip(".") + "."
 
 
 def get_keys(domain):
     """
     Retrieves a dict representation of the DNSSEC key information
     """
-    r = _pdns_get(NSLORD, '/zones/%s/cryptokeys' % pdns_id(domain.name))
-    metrics.get('desecapi_pdns_keys_fetched').inc()
+    r = _pdns_get(NSLORD, "/zones/%s/cryptokeys" % pdns_id(domain.name))
+    metrics.get("desecapi_pdns_keys_fetched").inc()
     field_map = {
-        'dnskey': 'dnskey',
-        'cds': 'ds',
-        'flags': 'flags',  # deprecated
-        'keytype': 'keytype',  # deprecated
+        "dnskey": "dnskey",
+        "cds": "ds",
+        "flags": "flags",  # deprecated
+        "keytype": "keytype",  # deprecated
     }
-    return [{v: key.get(k, []) for k, v in field_map.items()} for key in r.json() if key['published']]
+    return [
+        {v: key.get(k, []) for k, v in field_map.items()}
+        for key in r.json()
+        if key["published"]
+    ]
 
 
 def get_zone(domain):
     """
     Retrieves a dict representation of the zone from pdns
     """
-    r = _pdns_get(NSLORD, '/zones/' + pdns_id(domain.name))
+    r = _pdns_get(NSLORD, "/zones/" + pdns_id(domain.name))
 
     return r.json()
 
@@ -117,36 +169,45 @@ def get_rrset_datas(domain):
     """
     Retrieves a dict representation of the RRsets in a given zone
     """
-    return [{'domain': domain,
-             'subname': rrset['name'][:-(len(domain.name) + 2)],
-             'type': rrset['type'],
-             'records': [record['content'] for record in rrset['records']],
-             'ttl': rrset['ttl']}
-            for rrset in get_zone(domain)['rrsets']]
+    return [
+        {
+            "domain": domain,
+            "subname": rrset["name"][: -(len(domain.name) + 2)],
+            "type": rrset["type"],
+            "records": [record["content"] for record in rrset["records"]],
+            "ttl": rrset["ttl"],
+        }
+        for rrset in get_zone(domain)["rrsets"]
+    ]
 
 
-def construct_catalog_rrset(zone=None, delete=False, subname=None, qtype='PTR', rdata=None):
+def construct_catalog_rrset(
+    zone=None, delete=False, subname=None, qtype="PTR", rdata=None
+):
     # subname can be generated from zone for convenience; exactly one needs to be given
     assert (zone is None) ^ (subname is None)
     # sanity check: one can't delete an rrset and give record data at the same time
     assert not (delete and rdata)
 
     if subname is None:
-        zone = zone.rstrip('.') + '.'
+        zone = zone.rstrip(".") + "."
         m_unique = sha1(zone.encode()).hexdigest()
-        subname = f'{m_unique}.zones'
+        subname = f"{m_unique}.zones"
 
     if rdata is None:
         rdata = zone
 
     return {
-        'name': f'{subname}.{settings.CATALOG_ZONE}'.strip('.') + '.',
-        'type': qtype,
-        'ttl': 0,  # as per the specification
-        'changetype': 'REPLACE',
-        'records': [] if delete else [{'content': rdata, 'disabled': False}],
+        "name": f"{subname}.{settings.CATALOG_ZONE}".strip(".") + ".",
+        "type": qtype,
+        "ttl": 0,  # as per the specification
+        "changetype": "REPLACE",
+        "records": [] if delete else [{"content": rdata, "disabled": False}],
     }
 
 
 def get_serials():
-    return {zone['name']: zone['edited_serial'] for zone in _pdns_get(NSMASTER, '/zones').json()}
+    return {
+        zone["name"]: zone["edited_serial"]
+        for zone in _pdns_get(NSMASTER, "/zones").json()
+    }

+ 177 - 95
api/desecapi/pdns_change_tracker.py

@@ -7,8 +7,16 @@ from django.utils import timezone
 
 from desecapi import metrics
 from desecapi.models import RRset, RR, Domain
-from desecapi.pdns import _pdns_post, NSLORD, NSMASTER, _pdns_delete, _pdns_patch, _pdns_put, pdns_id, \
-    construct_catalog_rrset
+from desecapi.pdns import (
+    _pdns_post,
+    NSLORD,
+    NSMASTER,
+    _pdns_delete,
+    _pdns_patch,
+    _pdns_put,
+    pdns_id,
+    construct_catalog_rrset,
+)
 
 
 class PDNSChangeTracker:
@@ -55,7 +63,7 @@ class PDNSChangeTracker:
 
         @property
         def domain_name_normalized(self):
-            return self._domain_name + '.'
+            return self._domain_name + "."
 
         @property
         def domain_pdns_id(self):
@@ -72,9 +80,16 @@ class PDNSChangeTracker:
             raise NotImplementedError()
 
         def update_catalog(self, delete=False):
-            content = _pdns_patch(NSMASTER, '/zones/' + pdns_id(settings.CATALOG_ZONE),
-                               {'rrsets': [construct_catalog_rrset(zone=self.domain_name, delete=delete)]})
-            metrics.get('desecapi_pdns_catalog_updated').inc()
+            content = _pdns_patch(
+                NSMASTER,
+                "/zones/" + pdns_id(settings.CATALOG_ZONE),
+                {
+                    "rrsets": [
+                        construct_catalog_rrset(zone=self.domain_name, delete=delete)
+                    ]
+                },
+            )
+            metrics.get("desecapi_pdns_catalog_updated").inc()
             return content
 
     class CreateDomain(PDNSChange):
@@ -84,38 +99,44 @@ class PDNSChangeTracker:
 
         def pdns_do(self):
             _pdns_post(
-                NSLORD, '/zones?rrsets=false',
+                NSLORD,
+                "/zones?rrsets=false",
                 {
-                    'name': self.domain_name_normalized,
-                    'kind': 'MASTER',
-                    'dnssec': True,
-                    'nsec3param': '1 0 0 -',
-                    'nameservers': settings.DEFAULT_NS,
-                    'rrsets': [{
-                        'name': self.domain_name_normalized,
-                        'type': 'SOA',
-                        # SOA RRset TTL: 300 (used as TTL for negative replies including NSEC3 records)
-                        'ttl': 300,
-                        'records': [{
-                            # SOA refresh: 1 day (only needed for nslord --> nsmaster replication after RRSIG rotation)
-                            # SOA retry = 1h
-                            # SOA expire: 4 weeks (all signatures will have expired anyways)
-                            # SOA minimum: 3600 (for CDS, CDNSKEY, DNSKEY, NSEC3PARAM)
-                            'content': 'get.desec.io. get.desec.io. 1 86400 3600 2419200 3600',
-                            'disabled': False
-                        }],
-                    }],
-                }
+                    "name": self.domain_name_normalized,
+                    "kind": "MASTER",
+                    "dnssec": True,
+                    "nsec3param": "1 0 0 -",
+                    "nameservers": settings.DEFAULT_NS,
+                    "rrsets": [
+                        {
+                            "name": self.domain_name_normalized,
+                            "type": "SOA",
+                            # SOA RRset TTL: 300 (used as TTL for negative replies including NSEC3 records)
+                            "ttl": 300,
+                            "records": [
+                                {
+                                    # SOA refresh: 1 day (only needed for nslord --> nsmaster replication after RRSIG rotation)
+                                    # SOA retry = 1h
+                                    # SOA expire: 4 weeks (all signatures will have expired anyways)
+                                    # SOA minimum: 3600 (for CDS, CDNSKEY, DNSKEY, NSEC3PARAM)
+                                    "content": "get.desec.io. get.desec.io. 1 86400 3600 2419200 3600",
+                                    "disabled": False,
+                                }
+                            ],
+                        }
+                    ],
+                },
             )
 
             _pdns_post(
-                NSMASTER, '/zones?rrsets=false',
+                NSMASTER,
+                "/zones?rrsets=false",
                 {
-                    'name': self.domain_name_normalized,
-                    'kind': 'SLAVE',
-                    'masters': [socket.gethostbyname('nslord')],
-                    'master_tsig_key_ids': ['default'],
-                }
+                    "name": self.domain_name_normalized,
+                    "kind": "SLAVE",
+                    "masters": [socket.gethostbyname("nslord")],
+                    "master_tsig_key_ids": ["default"],
+                },
             )
 
             self.update_catalog()
@@ -123,7 +144,8 @@ class PDNSChangeTracker:
         def api_do(self):
             rr_set = RRset(
                 domain=Domain.objects.get(name=self.domain_name),
-                type='NS', subname='',
+                type="NS",
+                subname="",
                 ttl=settings.DEFAULT_NS_TTL,
             )
             rr_set.save()
@@ -132,7 +154,7 @@ class PDNSChangeTracker:
             RR.objects.bulk_create(rrs)  # One INSERT
 
         def __str__(self):
-            return 'Create Domain %s' % self.domain_name
+            return "Create Domain %s" % self.domain_name
 
     class DeleteDomain(PDNSChange):
         @property
@@ -140,15 +162,15 @@ class PDNSChangeTracker:
             return False
 
         def pdns_do(self):
-            _pdns_delete(NSLORD, '/zones/' + self.domain_pdns_id)
-            _pdns_delete(NSMASTER, '/zones/' + self.domain_pdns_id)
+            _pdns_delete(NSLORD, "/zones/" + self.domain_pdns_id)
+            _pdns_delete(NSMASTER, "/zones/" + self.domain_pdns_id)
             self.update_catalog(delete=True)
 
         def api_do(self):
             pass
 
         def __str__(self):
-            return 'Delete Domain %s' % self.domain_name
+            return "Delete Domain %s" % self.domain_name
 
     class CreateUpdateDeleteRRSets(PDNSChange):
         def __init__(self, domain_name, additions, modifications, deletions):
@@ -163,44 +185,54 @@ class PDNSChangeTracker:
 
         def pdns_do(self):
             data = {
-                'rrsets':
-                    [
-                        {
-                            'name': RRset.construct_name(subname, self._domain_name),
-                            'type': type_,
-                            'ttl': 1,  # some meaningless integer required by pdns's syntax
-                            'changetype': 'REPLACE',  # don't use "DELETE" due to desec-stack#220, PowerDNS/pdns#7501
-                            'records': []
-                        }
-                        for type_, subname in self._deletions
-                    ] + [
-                        {
-                            '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
-                    ]
+                "rrsets": [
+                    {
+                        "name": RRset.construct_name(subname, self._domain_name),
+                        "type": type_,
+                        "ttl": 1,  # some meaningless integer required by pdns's syntax
+                        "changetype": "REPLACE",  # don't use "DELETE" due to desec-stack#220, PowerDNS/pdns#7501
+                        "records": [],
+                    }
+                    for type_, subname in self._deletions
+                ]
+                + [
+                    {
+                        "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
+                ]
             }
 
-            if data['rrsets']:
-                _pdns_patch(NSLORD, '/zones/' + self.domain_pdns_id, data)
+            if data["rrsets"]:
+                _pdns_patch(NSLORD, "/zones/" + self.domain_pdns_id, data)
 
         def api_do(self):
             pass
 
         def __str__(self):
-            return 'Update RRsets of %s: additions=%s, modifications=%s, deletions=%s' % \
-                   (self.domain_name, list(self._additions), list(self._modifications), list(self._deletions))
+            return (
+                "Update RRsets of %s: additions=%s, modifications=%s, deletions=%s"
+                % (
+                    self.domain_name,
+                    list(self._additions),
+                    list(self._modifications),
+                    list(self._deletions),
+                )
+            )
 
     def __init__(self):
         self._domain_additions = set()
@@ -221,30 +253,44 @@ class PDNSChangeTracker:
             return f()
 
     def _manage_signals(self, method):
-        if method not in ['connect', 'disconnect']:
+        if method not in ["connect", "disconnect"]:
             raise ValueError()
-        getattr(post_save, method)(self._on_rr_post_save, sender=RR, dispatch_uid=self.__module__)
-        getattr(post_delete, method)(self._on_rr_post_delete, sender=RR, dispatch_uid=self.__module__)
-        getattr(post_save, method)(self._on_rr_set_post_save, sender=RRset, dispatch_uid=self.__module__)
-        getattr(post_delete, method)(self._on_rr_set_post_delete, sender=RRset, dispatch_uid=self.__module__)
-        getattr(post_save, method)(self._on_domain_post_save, sender=Domain, dispatch_uid=self.__module__)
-        getattr(post_delete, method)(self._on_domain_post_delete, sender=Domain, dispatch_uid=self.__module__)
+        getattr(post_save, method)(
+            self._on_rr_post_save, sender=RR, dispatch_uid=self.__module__
+        )
+        getattr(post_delete, method)(
+            self._on_rr_post_delete, sender=RR, dispatch_uid=self.__module__
+        )
+        getattr(post_save, method)(
+            self._on_rr_set_post_save, sender=RRset, dispatch_uid=self.__module__
+        )
+        getattr(post_delete, method)(
+            self._on_rr_set_post_delete, sender=RRset, dispatch_uid=self.__module__
+        )
+        getattr(post_save, method)(
+            self._on_domain_post_save, sender=Domain, dispatch_uid=self.__module__
+        )
+        getattr(post_delete, method)(
+            self._on_domain_post_delete, sender=Domain, dispatch_uid=self.__module__
+        )
 
     def __enter__(self):
         PDNSChangeTracker._active_change_trackers += 1
-        assert PDNSChangeTracker._active_change_trackers == 1, 'Nesting %s is not supported.' % self.__class__.__name__
+        assert PDNSChangeTracker._active_change_trackers == 1, (
+            "Nesting %s is not supported." % self.__class__.__name__
+        )
         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._manage_signals("connect")
         self.transaction = atomic()
         self.transaction.__enter__()
 
     def __exit__(self, exc_type, exc_val, exc_tb):
         PDNSChangeTracker._active_change_trackers -= 1
-        self._manage_signals('disconnect')
+        self._manage_signals("disconnect")
 
         if exc_type:
             # An exception occurred inside our context, exit db transaction and dismiss pdns changes
@@ -262,13 +308,15 @@ class PDNSChangeTracker:
                     axfr_required.add(change.domain_name)
             except Exception as e:
                 self.transaction.__exit__(type(e), e, e.__traceback__)
-                exc = ValueError(f'For changes {list(map(str, changes))}, {type(e)} occurred during {change}: {str(e)}')
+                exc = ValueError(
+                    f"For changes {list(map(str, changes))}, {type(e)} occurred during {change}: {str(e)}"
+                )
                 raise exc from e
 
         self.transaction.__exit__(None, None, None)
 
         for name in axfr_required:
-            _pdns_put(NSMASTER, '/zones/%s/axfr-retrieve' % pdns_id(name))
+            _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):
@@ -307,16 +355,21 @@ class PDNSChangeTracker:
             # 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
+                (type_, subname)
+                for (type_, subname) in additions
                 if not RR.objects.filter(
                     rrset__domain__name=domain_name,
                     rrset__type=type_,
-                    rrset__subname=subname).exists()
+                    rrset__subname=subname,
+                ).exists()
             }
 
             if additions | modifications | deletions:
-                changes.append(PDNSChangeTracker.CreateUpdateDeleteRRSets(
-                    domain_name, additions, modifications, deletions))
+                changes.append(
+                    PDNSChangeTracker.CreateUpdateDeleteRRSets(
+                        domain_name, additions, modifications, deletions
+                    )
+                )
 
         return changes
 
@@ -349,7 +402,9 @@ class PDNSChangeTracker:
             modifications.add(item)
             assert item not in deletions
         else:
-            raise ValueError('An RR set cannot be created and deleted at the same time.')
+            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:
@@ -361,7 +416,9 @@ class PDNSChangeTracker:
         deletions = self._domain_deletions
 
         if created and deleted:
-            raise ValueError('A domain set cannot be created and deleted at the same time.')
+            raise ValueError(
+                "A domain set cannot be created and deleted at the same time."
+            )
 
         if created:
             if name in deletions:
@@ -375,7 +432,9 @@ class PDNSChangeTracker:
                 deletions.add(name)
 
     # noinspection PyUnusedLocal
-    def _on_rr_post_save(self, signal, sender, instance: RR, created, update_fields, raw, using, **kwargs):
+    def _on_rr_post_save(
+        self, signal, sender, instance: RR, created, update_fields, raw, using, **kwargs
+    ):
         self._rr_set_updated(instance.rrset)
 
     # noinspection PyUnusedLocal
@@ -386,7 +445,17 @@ class PDNSChangeTracker:
             pass
 
     # noinspection PyUnusedLocal
-    def _on_rr_set_post_save(self, signal, sender, instance: RRset, created, update_fields, raw, using, **kwargs):
+    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
@@ -394,7 +463,17 @@ class PDNSChangeTracker:
         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):
+    def _on_domain_post_save(
+        self,
+        signal,
+        sender,
+        instance: Domain,
+        created,
+        update_fields,
+        raw,
+        using,
+        **kwargs,
+    ):
         self._domain_updated(instance, created=created)
 
     # noinspection PyUnusedLocal
@@ -402,10 +481,13 @@ class PDNSChangeTracker:
         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_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)
+        return (
+            "<%s: %i added or deleted domains; %i added, modified or deleted RR sets>"
+            % (self.__class__.__name__, len(all_domains), len(all_rr_sets))
         )

+ 18 - 8
api/desecapi/permissions.py

@@ -44,6 +44,7 @@ class TokenDomainPolicyBasePermission(permissions.BasePermission):
     """
     Base permission to check whether a token authorizes specific actions on a domain.
     """
+
     perm_field = None
 
     def _has_object_permission(self, request, view, obj):
@@ -70,14 +71,16 @@ class TokenHasDomainDynDNSPermission(TokenHasDomainBasePermission):
     """
     Custom permission to check whether a token authorizes using the dynDNS interface for the view domain.
     """
-    perm_field = 'perm_dyndns'
+
+    perm_field = "perm_dyndns"
 
 
 class TokenHasDomainRRsetsPermission(TokenHasDomainBasePermission):
     """
     Custom permission to check whether a token authorizes accessing RRsets for the view domain.
     """
-    perm_field = 'perm_rrsets'
+
+    perm_field = "perm_rrsets"
 
 
 class AuthTokenCorrespondsToViewToken(permissions.BasePermission):
@@ -86,18 +89,19 @@ class AuthTokenCorrespondsToViewToken(permissions.BasePermission):
     """
 
     def has_permission(self, request, view):
-        return view.kwargs['token_id'] == request.auth.pk
+        return view.kwargs["token_id"] == request.auth.pk
 
 
 class IsVPNClient(permissions.BasePermission):
     """
     Permission that requires that the user is accessing using an IP from the VPN net.
     """
-    message = 'Inadmissible client IP.'
+
+    message = "Inadmissible client IP."
 
     def has_permission(self, request, view):
-        ip = IPv4Address(request.META.get('REMOTE_ADDR'))
-        return ip in IPv4Network('10.8.0.0/24')
+        ip = IPv4Address(request.META.get("REMOTE_ADDR"))
+        return ip in IPv4Network("10.8.0.0/24")
 
 
 class HasManageTokensPermission(permissions.BasePermission):
@@ -113,7 +117,13 @@ class WithinDomainLimit(permissions.BasePermission):
     """
     Permission that requires that the user still has domain limit quota available.
     """
-    message = 'Domain limit exceeded. Please contact support to create additional domains.'
+
+    message = (
+        "Domain limit exceeded. Please contact support to create additional domains."
+    )
 
     def has_permission(self, request, view):
-        return request.user.limit_domains is None or request.user.domains.count() < request.user.limit_domains
+        return (
+            request.user.limit_domains is None
+            or request.user.domains.count() < request.user.limit_domains
+        )

+ 8 - 6
api/desecapi/renderers.py

@@ -6,19 +6,21 @@ from rest_framework import renderers
 
 class PlainTextRenderer(renderers.BaseRenderer):
     # Disregard Accept header
-    media_type = '*/*'
-    format = 'txt'
+    media_type = "*/*"
+    format = "txt"
 
     def render(self, data, media_type=None, renderer_context=None):
         renderer_context = renderer_context or {}
-        response = renderer_context.get('response')
+        response = renderer_context.get("response")
 
         if response and response.exception:
-            response['Content-Type'] = 'text/plain'
+            response["Content-Type"] = "text/plain"
             try:
-                return data['detail']
+                return data["detail"]
             except:
-                data = json.loads(json.dumps(data))  # stringify exception objects in potentially nested data structure
+                data = json.loads(
+                    json.dumps(data)
+                )  # stringify exception objects in potentially nested data structure
                 return yaml.safe_dump(data, default_flow_style=False)
 
         return data

+ 87 - 51
api/desecapi/serializers/authenticated_actions.py

@@ -21,7 +21,7 @@ class CustomFieldNameUniqueValidator(UniqueValidator):
     database query field; only how the lookup must match is allowed to be changed.)
     """
 
-    def __init__(self, queryset, message=None, lookup='exact', lookup_field=None):
+    def __init__(self, queryset, message=None, lookup="exact", lookup_field=None):
         self.lookup_field = lookup_field
         super().__init__(queryset, message, lookup)
 
@@ -29,7 +29,9 @@ class CustomFieldNameUniqueValidator(UniqueValidator):
         """
         Filter the queryset to all instances matching the given value on the specified lookup field.
         """
-        filter_kwargs = {'%s__%s' % (self.lookup_field or field_name, self.lookup): value}
+        filter_kwargs = {
+            "%s__%s" % (self.lookup_field or field_name, self.lookup): value
+        }
         return qs_filter(queryset, **filter_kwargs)
 
 
@@ -37,26 +39,34 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
     state = serializers.CharField()  # serializer read-write, but model read-only field
     validity_period = settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
 
-    _crypto_context = 'desecapi.serializers.AuthenticatedActionSerializer'
+    _crypto_context = "desecapi.serializers.AuthenticatedActionSerializer"
     timestamp = None  # is set to the code's timestamp during validation
 
     class Meta:
         model = models.AuthenticatedAction
-        fields = ('state',)
+        fields = ("state",)
 
     @classmethod
     def _pack_code(cls, data):
         payload = json.dumps(data).encode()
         code = crypto.encrypt(payload, context=cls._crypto_context).decode()
-        return code.rstrip('=')
+        return code.rstrip("=")
 
     @classmethod
     def _unpack_code(cls, code, *, ttl):
-        code += -len(code) % 4 * '='
+        code += -len(code) % 4 * "="
         try:
-            timestamp, payload = crypto.decrypt(code.encode(), context=cls._crypto_context, ttl=ttl)
+            timestamp, payload = crypto.decrypt(
+                code.encode(), context=cls._crypto_context, ttl=ttl
+            )
             return timestamp, json.loads(payload.decode())
-        except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError, binascii.Error):
+        except (
+            TypeError,
+            UnicodeDecodeError,
+            UnicodeEncodeError,
+            json.JSONDecodeError,
+            binascii.Error,
+        ):
             raise ValueError
 
     def to_representation(self, instance: models.AuthenticatedAction):
@@ -64,12 +74,12 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
         data = super().to_representation(instance)
 
         # encode into single string
-        return {'code': self._pack_code(data)}
+        return {"code": self._pack_code(data)}
 
     def to_internal_value(self, data):
         # Allow injecting validity period from context.  This is used, for example, for authentication, where the code's
         # integrity and timestamp is checked by AuthenticatedBasicUserActionSerializer with validity injected as needed.
-        validity_period = self.context.get('validity_period', self.validity_period)
+        validity_period = self.context.get("validity_period", self.validity_period)
         # calculate code TTL
         try:
             ttl = validity_period.total_seconds()
@@ -78,14 +88,16 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
 
         # decode from single string
         try:
-            self.timestamp, unpacked_data = self._unpack_code(self.context['code'], ttl=ttl)
+            self.timestamp, unpacked_data = self._unpack_code(
+                self.context["code"], ttl=ttl
+            )
         except KeyError:
-            raise serializers.ValidationError({'code': ['This field is required.']})
+            raise serializers.ValidationError({"code": ["This field is required."]})
         except ValueError:
             if ttl is None:
-                msg = 'This code is invalid.'
+                msg = "This code is invalid."
             else:
-                msg = f'This code is invalid, possibly because it expired (validity: {validity_period}).'
+                msg = f"This code is invalid, possibly because it expired (validity: {validity_period})."
             raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: msg})
 
         # add extra fields added by the user, but give precedence to fields unpacked from the code
@@ -102,24 +114,26 @@ class AuthenticatedActionSerializer(serializers.ModelSerializer):
         raise ValueError
 
 
-class AuthenticatedBasicUserActionMixin():
+class AuthenticatedBasicUserActionMixin:
     def save(self, **kwargs):
-        context = {**self.context, 'action_serializer': self}
+        context = {**self.context, "action_serializer": self}
         return self.action_user.send_email(self.reason, context=context, **kwargs)
 
 
-class AuthenticatedBasicUserActionSerializer(AuthenticatedBasicUserActionMixin, AuthenticatedActionSerializer):
+class AuthenticatedBasicUserActionSerializer(
+    AuthenticatedBasicUserActionMixin, AuthenticatedActionSerializer
+):
     user = serializers.PrimaryKeyRelatedField(
         queryset=models.User.objects.all(),
-        error_messages={'does_not_exist': 'This user does not exist.'},
-        pk_field=serializers.UUIDField()
+        error_messages={"does_not_exist": "This user does not exist."},
+        pk_field=serializers.UUIDField(),
     )
 
     reason = None
 
     class Meta:
         model = models.AuthenticatedBasicUserAction
-        fields = AuthenticatedActionSerializer.Meta.fields + ('user',)
+        fields = AuthenticatedActionSerializer.Meta.fields + ("user",)
 
     @property
     def action_user(self):
@@ -131,8 +145,9 @@ class AuthenticatedBasicUserActionSerializer(AuthenticatedBasicUserActionMixin,
         return cls(action).save()
 
 
-class AuthenticatedBasicUserActionListSerializer(AuthenticatedBasicUserActionMixin, serializers.ListSerializer):
-
+class AuthenticatedBasicUserActionListSerializer(
+    AuthenticatedBasicUserActionMixin, serializers.ListSerializer
+):
     @property
     def reason(self):
         return self.child.reason
@@ -141,100 +156,121 @@ class AuthenticatedBasicUserActionListSerializer(AuthenticatedBasicUserActionMix
     def action_user(self):
         user = self.instance[0].user
         if any(instance.user != user for instance in self.instance):
-            raise ValueError('Actions must belong to the same user.')
+            raise ValueError("Actions must belong to the same user.")
         return user
 
 
-class AuthenticatedChangeOutreachPreferenceUserActionSerializer(AuthenticatedBasicUserActionSerializer):
-    reason = 'change-outreach-preference'
+class AuthenticatedChangeOutreachPreferenceUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
+    reason = "change-outreach-preference"
     validity_period = None
 
     class Meta:
         model = models.AuthenticatedChangeOutreachPreferenceUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('outreach_preference',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + (
+            "outreach_preference",
+        )
 
 
 class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer):
     captcha = CaptchaSolutionSerializer(required=False)
 
-    reason = 'activate-account'
+    reason = "activate-account"
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedActivateUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('captcha', 'domain',)
-        extra_kwargs = {
-            'domain': {'default': None, 'allow_null': True}
-        }
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + (
+            "captcha",
+            "domain",
+        )
+        extra_kwargs = {"domain": {"default": None, "allow_null": True}}
 
     def validate(self, attrs):
         try:
-            attrs.pop('captcha')  # remove captcha from internal value to avoid passing to Meta.model(**kwargs)
+            attrs.pop(
+                "captcha"
+            )  # remove captcha from internal value to avoid passing to Meta.model(**kwargs)
         except KeyError:
-            if attrs['user'].needs_captcha:
-                raise serializers.ValidationError({'captcha': fields.Field.default_error_messages['required']})
+            if attrs["user"].needs_captcha:
+                raise serializers.ValidationError(
+                    {"captcha": fields.Field.default_error_messages["required"]}
+                )
         return attrs
 
 
-class AuthenticatedChangeEmailUserActionSerializer(AuthenticatedBasicUserActionSerializer):
+class AuthenticatedChangeEmailUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
     new_email = serializers.EmailField(
         validators=[
             CustomFieldNameUniqueValidator(
                 queryset=models.User.objects.all(),
-                lookup_field='email',
-                message='You already have another account with this email address.',
+                lookup_field="email",
+                message="You already have another account with this email address.",
             )
         ],
         required=True,
     )
 
-    reason = 'change-email'
+    reason = "change-email"
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedChangeEmailUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_email',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("new_email",)
 
     def save(self):
         return super().save(recipient=self.instance.new_email)
 
 
-class AuthenticatedConfirmAccountUserActionSerializer(AuthenticatedBasicUserActionSerializer):
-    reason = 'confirm-account'
+class AuthenticatedConfirmAccountUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
+    reason = "confirm-account"
     validity_period = timedelta(days=14)
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
-        model = models.AuthenticatedNoopUserAction  # confirmation happens during authentication, so nothing left to do
+        model = (
+            models.AuthenticatedNoopUserAction
+        )  # confirmation happens during authentication, so nothing left to do
 
 
-class AuthenticatedResetPasswordUserActionSerializer(AuthenticatedBasicUserActionSerializer):
+class AuthenticatedResetPasswordUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
     new_password = serializers.CharField(write_only=True)
 
-    reason = 'reset-password'
+    reason = "reset-password"
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedResetPasswordUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_password',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("new_password",)
 
 
 class AuthenticatedDeleteUserActionSerializer(AuthenticatedBasicUserActionSerializer):
-    reason = 'delete-account'
+    reason = "delete-account"
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedDeleteUserAction
 
 
-class AuthenticatedDomainBasicUserActionSerializer(AuthenticatedBasicUserActionSerializer):
+class AuthenticatedDomainBasicUserActionSerializer(
+    AuthenticatedBasicUserActionSerializer
+):
     domain = serializers.PrimaryKeyRelatedField(
         queryset=models.Domain.objects.all(),
-        error_messages={'does_not_exist': 'This domain does not exist.'},
+        error_messages={"does_not_exist": "This domain does not exist."},
     )
 
     class Meta:
         model = models.AuthenticatedDomainBasicUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('domain',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("domain",)
 
 
-class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasicUserActionSerializer):
-    reason = 'renew-domain'
+class AuthenticatedRenewDomainBasicUserActionSerializer(
+    AuthenticatedDomainBasicUserActionSerializer
+):
+    reason = "renew-domain"
     validity_period = None
 
     class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):

+ 12 - 6
api/desecapi/serializers/captcha.py

@@ -13,7 +13,11 @@ class CaptchaSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Captcha
-        fields = ('id', 'challenge', 'kind') if not settings.DEBUG else ('id', 'challenge', 'kind', 'content')
+        fields = (
+            ("id", "challenge", "kind")
+            if not settings.DEBUG
+            else ("id", "challenge", "kind", "content")
+        )
 
     def get_challenge(self, obj: Captcha):
         # TODO Does this need to be stored in the object instance, in case this method gets called twice?
@@ -22,20 +26,22 @@ class CaptchaSerializer(serializers.ModelSerializer):
         elif obj.kind == Captcha.Kind.AUDIO:
             challenge = AudioCaptcha().generate(obj.content)
         else:
-            raise ValueError(f'Unknown captcha type {obj.kind}')
+            raise ValueError(f"Unknown captcha type {obj.kind}")
         return b64encode(challenge)
 
 
 class CaptchaSolutionSerializer(serializers.Serializer):
     id = serializers.PrimaryKeyRelatedField(
         queryset=Captcha.objects.all(),
-        error_messages={'does_not_exist': 'CAPTCHA does not exist.'}
+        error_messages={"does_not_exist": "CAPTCHA does not exist."},
     )
     solution = serializers.CharField(write_only=True, required=True)
 
     def validate(self, attrs):
-        captcha = attrs['id']  # Note that this already is the Captcha object
-        if not captcha.verify(attrs['solution']):
-            raise serializers.ValidationError('CAPTCHA could not be validated. Please obtain a new one and try again.')
+        captcha = attrs["id"]  # Note that this already is the Captcha object
+        if not captcha.verify(attrs["solution"]):
+            raise serializers.ValidationError(
+                "CAPTCHA could not be validated. Please obtain a new one and try again."
+            )
 
         return attrs

+ 83 - 37
api/desecapi/serializers/domains.py

@@ -12,16 +12,27 @@ from .records import RRsetSerializer
 class DomainSerializer(serializers.ModelSerializer):
     default_error_messages = {
         **serializers.Serializer.default_error_messages,
-        'name_unavailable': 'This domain name conflicts with an existing zone, or is disallowed by policy.',
+        "name_unavailable": "This domain name conflicts with an existing zone, or is disallowed by policy.",
     }
     zonefile = serializers.CharField(write_only=True, required=False, allow_blank=True)
 
     class Meta:
         model = Domain
-        fields = ('created', 'published', 'name', 'keys', 'minimum_ttl', 'touched', 'zonefile')
-        read_only_fields = ('published', 'minimum_ttl',)
+        fields = (
+            "created",
+            "published",
+            "name",
+            "keys",
+            "minimum_ttl",
+            "touched",
+            "zonefile",
+        )
+        read_only_fields = (
+            "published",
+            "minimum_ttl",
+        )
         extra_kwargs = {
-            'name': {'trim_whitespace': False},
+            "name": {"trim_whitespace": False},
         }
 
     def __init__(self, *args, include_keys=False, **kwargs):
@@ -32,13 +43,15 @@ class DomainSerializer(serializers.ModelSerializer):
     def get_fields(self):
         fields = super().get_fields()
         if not self.include_keys:
-            fields.pop('keys')
-        fields['name'].validators.append(ReadOnlyOnUpdateValidator())
+            fields.pop("keys")
+        fields["name"].validators.append(ReadOnlyOnUpdateValidator())
         return fields
 
     def validate_name(self, value):
-        if not Domain(name=value, owner=self.context['request'].user).is_registrable():
-            raise serializers.ValidationError(self.default_error_messages['name_unavailable'], code='name_unavailable')
+        if not Domain(name=value, owner=self.context["request"].user).is_registrable():
+            raise serializers.ValidationError(
+                self.default_error_messages["name_unavailable"], code="name_unavailable"
+            )
         return value
 
     def parse_zonefile(self, domain_name: str, zonefile: str):
@@ -52,54 +65,83 @@ class DomainSerializer(serializers.ModelSerializer):
             )
         except dns.zonefile.CNAMEAndOtherData:
             raise serializers.ValidationError(
-                {'zonefile': ['No other records with the same name are allowed alongside a CNAME record.']})
+                {
+                    "zonefile": [
+                        "No other records with the same name are allowed alongside a CNAME record."
+                    ]
+                }
+            )
         except ValueError as e:
-            if 'has non-origin SOA' in str(e):
+            if "has non-origin SOA" in str(e):
                 raise serializers.ValidationError(
-                    {'zonefile': [f'Zonefile includes an SOA record for a name different from {domain_name}.']})
+                    {
+                        "zonefile": [
+                            f"Zonefile includes an SOA record for a name different from {domain_name}."
+                        ]
+                    }
+                )
             raise e
         except dns.exception.SyntaxError as e:
             try:
-                line = str(e).split(':')[1]
-                raise serializers.ValidationError({'zonefile': [f'Zonefile contains syntax error in line {line}.']})
+                line = str(e).split(":")[1]
+                raise serializers.ValidationError(
+                    {"zonefile": [f"Zonefile contains syntax error in line {line}."]}
+                )
             except IndexError:
-                raise serializers.ValidationError({'zonefile': [f'Could not parse zonefile: {str(e)}']})
+                raise serializers.ValidationError(
+                    {"zonefile": [f"Could not parse zonefile: {str(e)}"]}
+                )
 
     def validate(self, attrs):
-        if attrs.get('zonefile') is not None:
-            self.parse_zonefile(attrs.get('name'), attrs.pop('zonefile'))
+        if attrs.get("zonefile") is not None:
+            self.parse_zonefile(attrs.get("name"), attrs.pop("zonefile"))
         return super().validate(attrs)
 
     def create(self, validated_data):
         # save domain
-        if 'minimum_ttl' not in validated_data and Domain(name=validated_data['name']).is_locally_registrable:
+        if (
+            "minimum_ttl" not in validated_data
+            and Domain(name=validated_data["name"]).is_locally_registrable
+        ):
             validated_data.update(minimum_ttl=60)
         domain: Domain = super().create(validated_data)
 
         # save RRsets if zonefile was given
-        nodes = getattr(self.import_zone, 'nodes', None)
+        nodes = getattr(self.import_zone, "nodes", None)
         if nodes:
-            zone_name = dns.name.from_text(validated_data['name'])
+            zone_name = dns.name.from_text(validated_data["name"])
             min_ttl, max_ttl = domain.minimum_ttl, settings.MAXIMUM_TTL
             data = [
                 {
-                    'type': dns.rdatatype.to_text(rrset.rdtype),
-                    'ttl': max(min_ttl, min(max_ttl, rrset.ttl)),
-                    'subname': (owner_name - zone_name).to_text() if owner_name - zone_name != dns.name.empty else '',
-                    'records': [rr.to_text() for rr in rrset],
+                    "type": dns.rdatatype.to_text(rrset.rdtype),
+                    "ttl": max(min_ttl, min(max_ttl, rrset.ttl)),
+                    "subname": (owner_name - zone_name).to_text()
+                    if owner_name - zone_name != dns.name.empty
+                    else "",
+                    "records": [rr.to_text() for rr in rrset],
                 }
                 for owner_name, node in nodes.items()
                 for rrset in node.rdatasets
                 if (
-                    dns.rdatatype.to_text(rrset.rdtype) not in (
-                        RR_SET_TYPES_AUTOMATIC |  # do not import automatically managed record types
-                        {'CDS', 'CDNSKEY', 'DNSKEY'}  # do not import these, as this would likely be unexpected
+                    dns.rdatatype.to_text(rrset.rdtype)
+                    not in (
+                        RR_SET_TYPES_AUTOMATIC
+                        | {  # do not import automatically managed record types
+                            "CDS",
+                            "CDNSKEY",
+                            "DNSKEY",
+                        }  # do not import these, as this would likely be unexpected
                     )
-                    and not (owner_name - zone_name == dns.name.empty and rrset.rdtype == dns.rdatatype.NS)  # ignore apex NS
+                    and not (
+                        owner_name - zone_name == dns.name.empty
+                        and rrset.rdtype == dns.rdatatype.NS
+                    )  # ignore apex NS
                 )
             ]
 
-            rrset_list_serializer = RRsetSerializer(data=data, context=dict(domain=domain), many=True)
+            rrset_list_serializer = RRsetSerializer(
+                data=data, context=dict(domain=domain), many=True
+            )
             # The following line raises if data passed validation by dnspython during zone file parsing,
             # but is rejected by validation in RRsetSerializer. See also
             # test_create_domain_zonefile_import_validation
@@ -109,15 +151,19 @@ class DomainSerializer(serializers.ModelSerializer):
                 if isinstance(e.detail, serializers.ReturnList):
                     # match the order of error messages with the RRsets provided to the
                     # serializer to make sense to the client
-                    def fqdn(idx): return (data[idx]['subname'] + "." + domain.name).lstrip('.')
-                    raise serializers.ValidationError({
-                        'zonefile': [
-                            f"{fqdn(idx)}/{data[idx]['type']}: {err}"
-                            for idx, d in enumerate(e.detail)
-                            for _, errs in d.items()
-                            for err in errs
-                        ]
-                    })
+                    def fqdn(idx):
+                        return (data[idx]["subname"] + "." + domain.name).lstrip(".")
+
+                    raise serializers.ValidationError(
+                        {
+                            "zonefile": [
+                                f"{fqdn(idx)}/{data[idx]['type']}: {err}"
+                                for idx, d in enumerate(e.detail)
+                                for _, errs in d.items()
+                                for err in errs
+                            ]
+                        }
+                    )
 
                 raise e
 

+ 16 - 9
api/desecapi/serializers/donation.py

@@ -6,25 +6,32 @@ from desecapi import models
 
 
 class DonationSerializer(serializers.ModelSerializer):
-
     class Meta:
         model = models.Donation
-        fields = ('name', 'iban', 'bic', 'amount', 'message', 'email', 'mref', 'interval')
-        read_only_fields = ('mref',)
+        fields = (
+            "name",
+            "iban",
+            "bic",
+            "amount",
+            "message",
+            "email",
+            "mref",
+            "interval",
+        )
+        read_only_fields = ("mref",)
         extra_kwargs = {  # do not return sensitive information
-            'iban': {'write_only': True},
-            'bic': {'write_only': True},
-            'message': {'write_only': True},
+            "iban": {"write_only": True},
+            "bic": {"write_only": True},
+            "message": {"write_only": True},
         }
 
-
     @staticmethod
     def validate_bic(value):
-        return re.sub(r'[\s]', '', value)
+        return re.sub(r"[\s]", "", value)
 
     @staticmethod
     def validate_iban(value):
-        return re.sub(r'[\s]', '', value)
+        return re.sub(r"[\s]", "", value)
 
     def create(self, validated_data):
         return self.Meta.model(**validated_data)

+ 168 - 93
api/desecapi/serializers/records.py

@@ -32,7 +32,9 @@ class ConditionalExistenceModelSerializer(serializers.ModelSerializer):
         raise NotImplementedError
 
     def to_representation(self, instance):
-        return None if not self.exists(instance) else super().to_representation(instance)
+        return (
+            None if not self.exists(instance) else super().to_representation(instance)
+        )
 
     @property
     def data(self):
@@ -71,36 +73,38 @@ class NonBulkOnlyDefault:
     operations.
     Implementation inspired by CreateOnlyDefault.
     """
+
     requires_context = True
 
     def __init__(self, default):
         self.default = default
 
     def __call__(self, serializer_field):
-        is_many = getattr(serializer_field.root, 'many', False)
+        is_many = getattr(serializer_field.root, "many", False)
         if is_many:
             raise serializers.SkipField()
         if callable(self.default):
-            if getattr(self.default, 'requires_context', False):
+            if getattr(self.default, "requires_context", False):
                 return self.default(serializer_field)
             else:
                 return self.default()
         return self.default
 
     def __repr__(self):
-        return '%s(%s)' % (self.__class__.__name__, repr(self.default))
+        return "%s(%s)" % (self.__class__.__name__, repr(self.default))
 
 
 class RRSerializer(serializers.ModelSerializer):
-
     class Meta:
         model = models.RR
-        fields = ('content',)
+        fields = ("content",)
 
     def to_internal_value(self, data):
         if not isinstance(data, str):
-            raise serializers.ValidationError('Must be a string.', code='must-be-a-string')
-        return super().to_internal_value({'content': data})
+            raise serializers.ValidationError(
+                "Must be a string.", code="must-be-a-string"
+            )
+        return super().to_internal_value({"content": data})
 
     def to_representation(self, instance):
         return instance.content
@@ -110,12 +114,12 @@ class RRsetListSerializer(serializers.ListSerializer):
     default_error_messages = {
         **serializers.Serializer.default_error_messages,
         **serializers.ListSerializer.default_error_messages,
-        **{'not_a_list': 'Expected a list of items but got {input_type}.'},
+        **{"not_a_list": "Expected a list of items but got {input_type}."},
     }
 
     @staticmethod
     def _key(data_item):
-        return data_item.get('subname'), data_item.get('type')
+        return data_item.get("subname"), data_item.get("type")
 
     @staticmethod
     def _types_by_position_string(conflicting_indices_by_type):
@@ -124,24 +128,33 @@ class RRsetListSerializer(serializers.ListSerializer):
             for position in conflict_positions:
                 types_by_position.setdefault(position, []).append(type_)
         # Sort by position, None at the end
-        types_by_position = dict(sorted(types_by_position.items(), key=lambda x: (x[0] is None, x)))
+        types_by_position = dict(
+            sorted(types_by_position.items(), key=lambda x: (x[0] is None, x))
+        )
         db_conflicts = types_by_position.pop(None, None)
-        if db_conflicts: types_by_position['database'] = db_conflicts
+        if db_conflicts:
+            types_by_position["database"] = db_conflicts
         for position, types in types_by_position.items():
-            types_by_position[position] = ', '.join(sorted(types))
-        types_by_position = [f'{position} ({types})' for position, types in types_by_position.items()]
-        return ', '.join(types_by_position)
+            types_by_position[position] = ", ".join(sorted(types))
+        types_by_position = [
+            f"{position} ({types})" for position, types in types_by_position.items()
+        ]
+        return ", ".join(types_by_position)
 
     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')
+            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 serializers.SkipField()
             else:
-                self.fail('empty')
+                self.fail("empty")
 
         partial = self.partial
 
@@ -156,13 +169,19 @@ class RRsetListSerializer(serializers.ListSerializer):
         for idx, item in enumerate(data):
             # Validate data types before using anything from it
             if not isinstance(item, dict):
-                errors[idx].update(non_field_errors=f"Expected a dictionary, but got {type(item).__name__}.")
+                errors[idx].update(
+                    non_field_errors=f"Expected a dictionary, but got {type(item).__name__}."
+                )
                 continue
             s, t = self._key(item)  # subname, type
             if not (isinstance(s, str) or s is None):
-                errors[idx].update(subname=f"Expected a string, but got {type(s).__name__}.")
+                errors[idx].update(
+                    subname=f"Expected a string, but got {type(s).__name__}."
+                )
             if not (isinstance(t, str) or t is None):
-                errors[idx].update(type=f"Expected a string, but got {type(t).__name__}.")
+                errors[idx].update(
+                    type=f"Expected a string, but got {type(t).__name__}."
+                )
             if errors[idx]:
                 continue
 
@@ -170,7 +189,9 @@ class RRsetListSerializer(serializers.ListSerializer):
             # (although invalid), we make indices[s][t] a set to properly keep track. We also check and record RRsets
             # which are known in the database (once per subname), using index `None` (for checking CNAME exclusivity).
             if s not in indices:
-                types = self.child.domain.rrset_set.filter(subname=s).values_list('type', flat=True)
+                types = self.child.domain.rrset_set.filter(subname=s).values_list(
+                    "type", flat=True
+                )
                 indices[s] = {type_: {None} for type_ in types}
             items = indices[s].setdefault(t, set())
             items.add(idx)
@@ -179,7 +200,7 @@ class RRsetListSerializer(serializers.ListSerializer):
         for idx, item in enumerate(data):
             if errors[idx]:
                 continue
-            if item.get('records') == []:
+            if item.get("records") == []:
                 s, t = self._key(item)
                 collapsed_indices[s][t] -= {idx, None}
 
@@ -193,31 +214,40 @@ class RRsetListSerializer(serializers.ListSerializer):
                 s, t = self._key(item)
                 data_indices = indices[s][t] - {None}
                 if len(data_indices) > 1:
-                    raise serializers.ValidationError({
-                        'non_field_errors': [
-                            'Same subname and type as in position(s) %s, but must be unique.' %
-                            ', '.join(map(str, data_indices - {idx}))
-                        ]
-                    })
+                    raise serializers.ValidationError(
+                        {
+                            "non_field_errors": [
+                                "Same subname and type as in position(s) %s, but must be unique."
+                                % ", ".join(map(str, data_indices - {idx}))
+                            ]
+                        }
+                    )
 
                 # see if other rows violate CNAME exclusivity
-                if item.get('records') != []:
-                    conflicting_indices_by_type = {k: v for k, v in collapsed_indices[s].items()
-                                                   if (k == 'CNAME') != (t == 'CNAME')}
+                if item.get("records") != []:
+                    conflicting_indices_by_type = {
+                        k: v
+                        for k, v in collapsed_indices[s].items()
+                        if (k == "CNAME") != (t == "CNAME")
+                    }
                     if any(conflicting_indices_by_type.values()):
-                        types_by_position = self._types_by_position_string(conflicting_indices_by_type)
-                        raise serializers.ValidationError({
-                            'non_field_errors': [
-                                f'RRset with conflicting type present: {types_by_position}.'
-                                ' (No other RRsets are allowed alongside CNAME.)'
-                            ]
-                        })
+                        types_by_position = self._types_by_position_string(
+                            conflicting_indices_by_type
+                        )
+                        raise serializers.ValidationError(
+                            {
+                                "non_field_errors": [
+                                    f"RRset with conflicting type present: {types_by_position}."
+                                    " (No other RRsets are allowed alongside CNAME.)"
+                                ]
+                            }
+                        )
 
                 # 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) != []
+                nonempty = item.get("records", None) != []
                 self.partial = partial and not (unknown and nonempty)
                 self.child.instance = known_instances.get(self._key(item), None)
 
@@ -265,21 +295,28 @@ class RRsetListSerializer(serializers.ListSerializer):
         :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) == []
+            return data_item.get("records", None) == []
 
-        query = Q(pk__in=[])  # start out with an always empty query, see https://stackoverflow.com/q/35893867/6867099
+        query = Q(
+            pk__in=[]
+        )  # start out with an always empty query, see https://stackoverflow.com/q/35893867/6867099
         for item in validated_data:
-            query |= Q(type=item['type'], subname=item['subname'])  # validation has ensured these fields exist
+            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.')
+            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()
@@ -303,64 +340,78 @@ class RRsetListSerializer(serializers.ListSerializer):
             instance_index[(subname, type_)].delete()
 
         for subname, type_ in created:
-            ret.append(self.child.create(
-                validated_data=data_index[(subname, type_)]
-            ))
+            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_)]
-            ))
+            ret.append(
+                self.child.update(
+                    instance=instance_index[(subname, type_)],
+                    validated_data=data_index[(subname, type_)],
+                )
+            )
 
         return ret
 
     def save(self, **kwargs):
-        kwargs.setdefault('domain', self.child.domain)
+        kwargs.setdefault("domain", self.child.domain)
         return super().save(**kwargs)
 
 
 class RRsetSerializer(ConditionalExistenceModelSerializer):
-    domain = serializers.SlugRelatedField(read_only=True, slug_field='name')
+    domain = serializers.SlugRelatedField(read_only=True, slug_field="name")
     records = RRSerializer(many=True)
     ttl = serializers.IntegerField(max_value=settings.MAXIMUM_TTL)
 
     class Meta:
         model = models.RRset
-        fields = ('created', 'domain', 'subname', 'name', 'records', 'ttl', 'type', 'touched',)
+        fields = (
+            "created",
+            "domain",
+            "subname",
+            "name",
+            "records",
+            "ttl",
+            "type",
+            "touched",
+        )
         extra_kwargs = {
-            'subname': {'required': False, 'default': NonBulkOnlyDefault('')}
+            "subname": {"required": False, "default": NonBulkOnlyDefault("")}
         }
         list_serializer_class = RRsetListSerializer
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         try:
-            self.domain = self.context['domain']
+            self.domain = self.context["domain"]
         except KeyError:
-            raise ValueError('RRsetSerializer() must be given a domain object (to validate uniqueness constraints).')
-        self.minimum_ttl = self.context.get('minimum_ttl', self.domain.minimum_ttl)
+            raise ValueError(
+                "RRsetSerializer() must be given a domain object (to validate uniqueness constraints)."
+            )
+        self.minimum_ttl = self.context.get("minimum_ttl", self.domain.minimum_ttl)
 
     def get_fields(self):
         fields = super().get_fields()
-        fields['subname'].validators.append(ReadOnlyOnUpdateValidator())
-        fields['type'].validators.append(ReadOnlyOnUpdateValidator())
-        fields['ttl'].validators.append(MinValueValidator(limit_value=self.minimum_ttl))
+        fields["subname"].validators.append(ReadOnlyOnUpdateValidator())
+        fields["type"].validators.append(ReadOnlyOnUpdateValidator())
+        fields["ttl"].validators.append(MinValueValidator(limit_value=self.minimum_ttl))
         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.',
+                ("subname", "type"),
+                message="Another RRset with the same subdomain and type exists for this domain.",
             ),
             ExclusionConstraintValidator(
                 self.domain.rrset_set,
-                ('subname',),
-                exclusion_condition=('type', 'CNAME',),
-                message='RRset with conflicting type present: database ({types}).'
-                        ' (No other RRsets are allowed alongside CNAME.)',
+                ("subname",),
+                exclusion_condition=(
+                    "type",
+                    "CNAME",
+                ),
+                message="RRset with conflicting type present: database ({types})."
+                " (No other RRsets are allowed alongside CNAME.)",
             ),
         ]
 
@@ -369,20 +420,28 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         if value not in models.RR_SET_TYPES_MANAGEABLE:
             # user cannot manage this type, let's try to tell her the reason
             if value in models.RR_SET_TYPES_AUTOMATIC:
-                raise serializers.ValidationError(f'You cannot tinker with the {value} RR set. It is managed '
-                                                  f'automatically.')
-            elif value.startswith('TYPE'):
-                raise serializers.ValidationError('Generic type format is not supported.')
+                raise serializers.ValidationError(
+                    f"You cannot tinker with the {value} RR set. It is managed "
+                    f"automatically."
+                )
+            elif value.startswith("TYPE"):
+                raise serializers.ValidationError(
+                    "Generic type format is not supported."
+                )
             else:
-                raise serializers.ValidationError(f'The {value} RR set type is currently unsupported.')
+                raise serializers.ValidationError(
+                    f"The {value} RR set type is currently unsupported."
+                )
         return value
 
     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.')
+        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 validate_subname(self, value):
@@ -390,19 +449,27 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
             dns.name.from_text(value, dns.name.from_text(self.domain.name))
         except dns.name.NameTooLong:
             raise serializers.ValidationError(
-                'This field combined with the domain name must not exceed 255 characters.', code='name_too_long')
+                "This field combined with the domain name must not exceed 255 characters.",
+                code="name_too_long",
+            )
         return value
 
     def validate(self, attrs):
-        if 'records' in attrs:
+        if "records" in attrs:
             try:
-                type_ = attrs['type']
+                type_ = attrs["type"]
             except KeyError:  # on the RRsetDetail endpoint, the type is not in attrs
                 type_ = self.instance.type
 
             try:
-                attrs['records'] = [{'content': models.RR.canonical_presentation_format(rr['content'], type_)}
-                                    for rr in attrs['records']]
+                attrs["records"] = [
+                    {
+                        "content": models.RR.canonical_presentation_format(
+                            rr["content"], type_
+                        )
+                    }
+                    for rr in attrs["records"]
+                ]
             except ValueError as ex:
                 raise serializers.ValidationError(str(ex))
 
@@ -411,16 +478,24 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
             # There also seems to be a 32 byte (?) baseline requirement per RRset, plus the qname length, see
             # https://lists.isc.org/pipermail/bind-users/2008-April/070148.html
             # The binary length of the record depends actually on the type, but it's never longer than vanilla len()
-            qname = models.RRset.construct_name(attrs.get('subname', ''), self.domain.name)
-            conservative_total_length = 32 + len(qname) + sum(12 + len(rr['content']) for rr in attrs['records'])
+            qname = models.RRset.construct_name(
+                attrs.get("subname", ""), self.domain.name
+            )
+            conservative_total_length = (
+                32
+                + len(qname)
+                + sum(12 + len(rr["content"]) for rr in attrs["records"])
+            )
 
             # Add some leeway for RRSIG record (really ~110 bytes) and other data we have not thought of
             conservative_total_length += 256
 
             excess_length = conservative_total_length - 65535  # max response size
             if excess_length > 0:
-                raise serializers.ValidationError(f'Total length of RRset exceeds limit by {excess_length} bytes.',
-                                                  code='max_length')
+                raise serializers.ValidationError(
+                    f"Total length of RRset exceeds limit by {excess_length} bytes.",
+                    code="max_length",
+                )
 
         return attrs
 
@@ -428,20 +503,20 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         if isinstance(arg, models.RRset):
             return arg.records.exists() if arg.pk else False
         else:
-            return bool(arg.get('records')) if 'records' in arg.keys() else True
+            return bool(arg.get("records")) if "records" in arg.keys() else True
 
     def create(self, validated_data):
-        rrs_data = validated_data.pop('records')
+        rrs_data = validated_data.pop("records")
         rrset = models.RRset.objects.create(**validated_data)
         self._set_all_record_contents(rrset, rrs_data)
         return rrset
 
     def update(self, instance: models.RRset, validated_data):
-        rrs_data = validated_data.pop('records', None)
+        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)
+        ttl = validated_data.pop("ttl", None)
         if ttl and instance.ttl != ttl:
             instance.ttl = ttl
             instance.save()  # also updates instance.touched
@@ -452,7 +527,7 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         return instance
 
     def save(self, **kwargs):
-        kwargs.setdefault('domain', self.domain)
+        kwargs.setdefault("domain", self.domain)
         return super().save(**kwargs)
 
     @staticmethod
@@ -463,8 +538,8 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
         :param rrset: the RRset at which we overwrite all RRs
         :param rrs: list of RR representations
         """
-        record_contents = [rr['content'] for rr in rrs]
+        record_contents = [rr["content"] for rr in rrs]
         try:
             rrset.save_records(record_contents)
         except django.core.exceptions.ValidationError as e:
-            raise serializers.ValidationError(e.messages, code='record-content')
+            raise serializers.ValidationError(e.messages, code="record-content")

+ 32 - 13
api/desecapi/serializers/tokens.py

@@ -6,15 +6,27 @@ from desecapi.models import Token, TokenDomainPolicy
 
 
 class TokenSerializer(serializers.ModelSerializer):
-    allowed_subnets = serializers.ListField(child=netfields_rf.CidrAddressField(), required=False)
-    token = serializers.ReadOnlyField(source='plain')
+    allowed_subnets = serializers.ListField(
+        child=netfields_rf.CidrAddressField(), required=False
+    )
+    token = serializers.ReadOnlyField(source="plain")
     is_valid = serializers.ReadOnlyField()
 
     class Meta:
         model = Token
-        fields = ('id', 'created', 'last_used', 'max_age', 'max_unused_period', 'name', 'perm_manage_tokens',
-                  'allowed_subnets', 'is_valid', 'token',)
-        read_only_fields = ('id', 'created', 'last_used', 'token')
+        fields = (
+            "id",
+            "created",
+            "last_used",
+            "max_age",
+            "max_unused_period",
+            "name",
+            "perm_manage_tokens",
+            "allowed_subnets",
+            "is_valid",
+            "token",
+        )
+        read_only_fields = ("id", "created", "last_used", "token")
 
     def __init__(self, *args, include_plain=False, **kwargs):
         self.include_plain = include_plain
@@ -23,29 +35,36 @@ class TokenSerializer(serializers.ModelSerializer):
     def get_fields(self):
         fields = super().get_fields()
         if not self.include_plain:
-            fields.pop('token')
+            fields.pop("token")
         return fields
 
 
 class DomainSlugRelatedField(serializers.SlugRelatedField):
-
     def get_queryset(self):
-        return self.context['request'].user.domains
+        return self.context["request"].user.domains
 
 
 class TokenDomainPolicySerializer(serializers.ModelSerializer):
-    domain = DomainSlugRelatedField(allow_null=True, slug_field='name')
+    domain = DomainSlugRelatedField(allow_null=True, slug_field="name")
 
     class Meta:
         model = TokenDomainPolicy
-        fields = ('domain', 'perm_dyndns', 'perm_rrsets',)
+        fields = (
+            "domain",
+            "perm_dyndns",
+            "perm_rrsets",
+        )
 
     def to_internal_value(self, data):
-        return {**super().to_internal_value(data),
-                'token': self.context['request'].user.token_set.get(id=self.context['view'].kwargs['token_id'])}
+        return {
+            **super().to_internal_value(data),
+            "token": self.context["request"].user.token_set.get(
+                id=self.context["view"].kwargs["token_id"]
+            ),
+        }
 
     def save(self, **kwargs):
         try:
             return super().save(**kwargs)
         except django.core.exceptions.ValidationError as exc:
-            raise serializers.ValidationError(exc.message_dict, code='precedence')
+            raise serializers.ValidationError(exc.message_dict, code="precedence")

+ 33 - 15
api/desecapi/serializers/users.py

@@ -19,8 +19,8 @@ class ChangeEmailSerializer(serializers.Serializer):
     new_email = serializers.EmailField()
 
     def validate_new_email(self, value):
-        if value == self.context['request'].user.email:
-            raise serializers.ValidationError('Email address unchanged.')
+        if value == self.context["request"].user.email:
+            raise serializers.ValidationError("Email address unchanged.")
         return value
 
 
@@ -29,11 +29,21 @@ class ResetPasswordSerializer(EmailSerializer):
 
 
 class UserSerializer(serializers.ModelSerializer):
-
     class Meta:
         model = User
-        fields = ('created', 'email', 'id', 'limit_domains', 'outreach_preference',)
-        read_only_fields = ('created', 'email', 'id', 'limit_domains',)
+        fields = (
+            "created",
+            "email",
+            "id",
+            "limit_domains",
+            "outreach_preference",
+        )
+        read_only_fields = (
+            "created",
+            "email",
+            "id",
+            "limit_domains",
+        )
 
     def validate_password(self, value):
         if value is not None:
@@ -50,11 +60,17 @@ class RegisterAccountSerializer(UserSerializer):
 
     class Meta:
         model = UserSerializer.Meta.model
-        fields = ('email', 'password', 'domain', 'captcha', 'outreach_preference',)
+        fields = (
+            "email",
+            "password",
+            "domain",
+            "captcha",
+            "outreach_preference",
+        )
         extra_kwargs = {
-            'password': {
-                'write_only': True,  # Do not expose password field
-                'allow_null': True,
+            "password": {
+                "write_only": True,  # Do not expose password field
+                "allow_null": True,
             }
         }
 
@@ -63,14 +79,16 @@ class RegisterAccountSerializer(UserSerializer):
         try:
             serializer.is_valid(raise_exception=True)
         except serializers.ValidationError:
-            raise serializers.ValidationError(serializer.default_error_messages['name_unavailable'],
-                                              code='name_unavailable')
+            raise serializers.ValidationError(
+                serializer.default_error_messages["name_unavailable"],
+                code="name_unavailable",
+            )
         return value
 
     def create(self, validated_data):
-        validated_data.pop('domain', None)
+        validated_data.pop("domain", None)
         # If validated_data['captcha'] exists, the captcha was also validated, so we can set the user to verified
-        if 'captcha' in validated_data:
-            validated_data.pop('captcha')
-            validated_data['needs_captcha'] = False
+        if "captcha" in validated_data:
+            validated_data.pop("captcha")
+            validated_data["needs_captcha"] = False
         return super().create(validated_data)

+ 3 - 1
api/desecapi/signals.py

@@ -5,5 +5,7 @@ from desecapi import models
 
 
 @receiver(post_save, sender=models.Domain, dispatch_uid=__name__)
-def domain_handler(sender, instance: models.Domain, created, raw, using, update_fields, **kwargs):
+def domain_handler(
+    sender, instance: models.Domain, created, raw, using, update_fields, **kwargs
+):
     pass

+ 9 - 3
api/desecapi/templatetags/action_extras.py

@@ -9,9 +9,15 @@ register = template.Library()
 
 @register.simple_tag
 def action_link(action_serializer, idx=None):
-    view_name = f'v1:confirm-{action_serializer.reason}'
-    code = action_serializer.data['code'] if idx is None else action_serializer.data[idx]['code']
-    return f'https://desec.{settings.DESECSTACK_DOMAIN}' + reverse(view_name, args=[code])
+    view_name = f"v1:confirm-{action_serializer.reason}"
+    code = (
+        action_serializer.data["code"]
+        if idx is None
+        else action_serializer.data[idx]["code"]
+    )
+    return f"https://desec.{settings.DESECSTACK_DOMAIN}" + reverse(
+        view_name, args=[code]
+    )
 
 
 @register.simple_tag

+ 4 - 4
api/desecapi/templatetags/sepa_extras.py

@@ -8,10 +8,10 @@ register = template.Library()
 
 def clean(value):
     """Replaces non-ascii characters with their closest ascii
-       representation and then removes everything but [A-Za-z0-9 ]"""
-    normalized = unicodedata.normalize('NFKD', value)
-    cleaned = re.sub(r'[^A-Za-z0-9 ]', '', normalized)
+    representation and then removes everything but [A-Za-z0-9 ]"""
+    normalized = unicodedata.normalize("NFKD", value)
+    cleaned = re.sub(r"[^A-Za-z0-9 ]", "", normalized)
     return cleaned
 
 
-register.filter('clean', clean)
+register.filter("clean", clean)

文件差異過大導致無法顯示
+ 419 - 217
api/desecapi/tests/base.py


+ 125 - 51
api/desecapi/tests/test_authentication.py

@@ -14,7 +14,7 @@ class DynUpdateAuthenticationTestCase(DynDomainOwnerTestCase):
 
     def _get_dyndns12(self):
         with self.assertPdnsNoRequestsBut(self.requests_desec_rr_sets_update()):
-            return self.client.get(self.reverse('v1:dyndns12update'))
+            return self.client.get(self.reverse("v1:dyndns12update"))
 
     def assertDynDNS12Status(self, code=HTTP_200_OK, authorization=None):
         if authorization:
@@ -27,43 +27,61 @@ class DynUpdateAuthenticationTestCase(DynDomainOwnerTestCase):
             self.client.set_credentials_basic_auth(username, token)
             self.assertDynDNS12Status(code)
 
-        assertDynDNS12AuthenticationStatus('', self.token.plain, HTTP_200_OK)
-        assertDynDNS12AuthenticationStatus(self.owner.get_username(), self.token.plain, HTTP_200_OK)
-        assertDynDNS12AuthenticationStatus(self.my_domain.name, self.token.plain, HTTP_200_OK)
-        assertDynDNS12AuthenticationStatus(' ' + self.my_domain.name, self.token.plain, HTTP_401_UNAUTHORIZED)
-        assertDynDNS12AuthenticationStatus('wrong', self.token.plain, HTTP_401_UNAUTHORIZED)
-        assertDynDNS12AuthenticationStatus('', 'wrong', HTTP_401_UNAUTHORIZED)
-        assertDynDNS12AuthenticationStatus(self.user.get_username(), 'wrong', HTTP_401_UNAUTHORIZED)
+        assertDynDNS12AuthenticationStatus("", self.token.plain, HTTP_200_OK)
+        assertDynDNS12AuthenticationStatus(
+            self.owner.get_username(), self.token.plain, HTTP_200_OK
+        )
+        assertDynDNS12AuthenticationStatus(
+            self.my_domain.name, self.token.plain, HTTP_200_OK
+        )
+        assertDynDNS12AuthenticationStatus(
+            " " + self.my_domain.name, self.token.plain, HTTP_401_UNAUTHORIZED
+        )
+        assertDynDNS12AuthenticationStatus(
+            "wrong", self.token.plain, HTTP_401_UNAUTHORIZED
+        )
+        assertDynDNS12AuthenticationStatus("", "wrong", HTTP_401_UNAUTHORIZED)
+        assertDynDNS12AuthenticationStatus(
+            self.user.get_username(), "wrong", HTTP_401_UNAUTHORIZED
+        )
 
     def test_malformed_basic_auth(self):
         for authorization in [
-            'asdf:asdf:sadf',
-            'asdf',
-            'bull[%]shit',
-            '你好',
-            '💩💩💩💩',
-            '💩💩:💩💩',
+            "asdf:asdf:sadf",
+            "asdf",
+            "bull[%]shit",
+            "你好",
+            "💩💩💩💩",
+            "💩💩:💩💩",
         ]:
-            self.assertDynDNS12Status(authorization=authorization, code=HTTP_401_UNAUTHORIZED)
+            self.assertDynDNS12Status(
+                authorization=authorization, code=HTTP_401_UNAUTHORIZED
+            )
 
 
 class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
-
     def setUp(self):
         super().setUp()
         # Refresh token from database, but keep plain value
-        self.token, self.token.plain = Token.objects.get(pk=self.token.pk), self.token.plain
+        self.token, self.token.plain = (
+            Token.objects.get(pk=self.token.pk),
+            self.token.plain,
+        )
 
-    def assertAuthenticationStatus(self, code, plain=None, expired=False ,**kwargs):
+    def assertAuthenticationStatus(self, code, plain=None, expired=False, **kwargs):
         plain = plain or self.token.plain
         self.client.set_credentials_token_auth(plain)
 
         # only forward REMOTE_ADDR if not None
-        if kwargs.get('REMOTE_ADDR') is None:
-            kwargs.pop('REMOTE_ADDR', None)
-
-        response = self.client.get(self.reverse('v1:root'), **kwargs)
-        body = json.dumps({'detail': 'Invalid token.'}) if code == HTTP_401_UNAUTHORIZED else None
+        if kwargs.get("REMOTE_ADDR") is None:
+            kwargs.pop("REMOTE_ADDR", None)
+
+        response = self.client.get(self.reverse("v1:root"), **kwargs)
+        body = (
+            json.dumps({"detail": "Invalid token."})
+            if code == HTTP_401_UNAUTHORIZED
+            else None
+        )
         self.assertResponse(response, code, body)
 
         if expired:
@@ -78,16 +96,18 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
     def test_token_subnets(self):
         datas = [  # Format: allowed_subnets, status, client_ip | None, [client_ip, ...]
             ([], HTTP_401_UNAUTHORIZED, None),
-            (['127.0.0.1'], HTTP_200_OK, None),
-            (['1.2.3.4'], HTTP_401_UNAUTHORIZED, None),
-            (['1.2.3.4'], HTTP_200_OK, '1.2.3.4'),
-            (['1.2.3.0/24'], HTTP_200_OK, '1.2.3.4'),
-            (['1.2.3.0/24'], HTTP_401_UNAUTHORIZED, 'bade::affe'),
-            (['bade::/64'], HTTP_200_OK, 'bade::affe'),
-            (['bade::/64', '1.2.3.0/24'], HTTP_200_OK, 'bade::affe', '1.2.3.66'),
+            (["127.0.0.1"], HTTP_200_OK, None),
+            (["1.2.3.4"], HTTP_401_UNAUTHORIZED, None),
+            (["1.2.3.4"], HTTP_200_OK, "1.2.3.4"),
+            (["1.2.3.0/24"], HTTP_200_OK, "1.2.3.4"),
+            (["1.2.3.0/24"], HTTP_401_UNAUTHORIZED, "bade::affe"),
+            (["bade::/64"], HTTP_200_OK, "bade::affe"),
+            (["bade::/64", "1.2.3.0/24"], HTTP_200_OK, "bade::affe", "1.2.3.66"),
         ]
 
-        for allowed_subnets, status, client_ips in ((*data[:2], data[2:]) for data in datas):
+        for allowed_subnets, status, client_ips in (
+            (*data[:2], data[2:]) for data in datas
+        ):
             self.token.allowed_subnets = allowed_subnets
             self.token.save()
             for client_ip in client_ips:
@@ -99,7 +119,10 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
 
         self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + timedelta(days=3650)):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=timezone.now() + timedelta(days=3650),
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
 
         # Maximum age zero: token cannot be used
@@ -113,9 +136,15 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
 
         second = timedelta(seconds=1)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period - second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + period - second,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period + second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + period + second,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
 
     def test_token_max_unused_period(self):
@@ -134,24 +163,46 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
 
         # Can't use after period if token was never used (last_used is None)
         self.assertIsNone(self.token.last_used)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period + second):
-            self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, plain=plain, expired=True)
-            self.assertIsNone(Token.objects.get(pk=self.token.pk).last_used)  # unchanged
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + period + second,
+        ):
+            self.assertAuthenticationStatus(
+                HTTP_401_UNAUTHORIZED, plain=plain, expired=True
+            )
+            self.assertIsNone(
+                Token.objects.get(pk=self.token.pk).last_used
+            )  # unchanged
 
         # Can use after half the period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + period/2):
+        with mock.patch(
+            "desecapi.models.timezone.now", return_value=self.token.created + period / 2
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
         self.token = Token.objects.get(pk=self.token.pk)  # update last_used field
 
         # Can't use once another period is over
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.last_used + period + second):
-            self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, plain=plain, expired=True)
-            self.assertEqual(self.token.last_used, Token.objects.get(pk=self.token.pk).last_used)  # unchanged
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.last_used + period + second,
+        ):
+            self.assertAuthenticationStatus(
+                HTTP_401_UNAUTHORIZED, plain=plain, expired=True
+            )
+            self.assertEqual(
+                self.token.last_used, Token.objects.get(pk=self.token.pk).last_used
+            )  # unchanged
 
         # ... but one second before, and also for one more period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.last_used + period - second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.last_used + period - second,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.last_used + 2*period - 2*second):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.last_used + 2 * period - 2 * second,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
 
         # No maximum age: can use now and in ten years
@@ -159,7 +210,10 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
 
         self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
-        with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + timedelta(days=3650)):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=timezone.now() + timedelta(days=3650),
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK, plain=plain)
 
     def test_token_max_age_max_unused_period(self):
@@ -169,28 +223,48 @@ class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
         self.token.save()
 
         # max_unused_period wins if tighter than max_age
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 1.25*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 1.25 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
 
         # Can use immediately
         self.assertAuthenticationStatus(HTTP_200_OK)
 
         # Can use continuously within max_unused_period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 0.75*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 0.75 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 1.5*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now", return_value=self.token.created + 1.5 * hour
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
 
         # max_unused_period wins again if tighter than max_age
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 2.75*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 2.75 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)
 
         # Can use continuously within max_unused_period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 2.25*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 2.25 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 2.75*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 2.75 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_200_OK)
 
         # max_age wins again if tighter than max_unused_period
-        with mock.patch('desecapi.models.timezone.now', return_value=self.token.created + 3.25*hour):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=self.token.created + 3.25 * hour,
+        ):
             self.assertAuthenticationStatus(HTTP_401_UNAUTHORIZED, expired=True)

+ 22 - 20
api/desecapi/tests/test_captcha.py

@@ -16,9 +16,8 @@ from desecapi.tests.base import DesecTestCase
 
 
 class CaptchaClient(APIClient):
-
     def obtain(self, **kwargs):
-        return self.post(reverse('v1:captcha'), data=kwargs)
+        return self.post(reverse("v1:captcha"), data=kwargs)
 
 
 class CaptchaModelTestCase(TestCase):
@@ -27,13 +26,13 @@ class CaptchaModelTestCase(TestCase):
     def test_random_initialization(self):
         captcha = [self.captcha_class() for _ in range(2)]
         self.assertNotEqual(captcha[0].content, None)
-        self.assertNotEqual(captcha[0].content, '')
+        self.assertNotEqual(captcha[0].content, "")
         self.assertNotEqual(captcha[0].content, captcha[1].content)
 
     def test_verify_solution(self):
         for _ in range(10):
             c = self.captcha_class.objects.create()
-            self.assertFalse(c.verify('likely the wrong solution!'))
+            self.assertFalse(c.verify("likely the wrong solution!"))
             c = self.captcha_class.objects.create()
             self.assertTrue(c.verify(c.content))
 
@@ -53,7 +52,7 @@ class CaptchaWorkflowTestCase(DesecTestCase):
         :return: whether the id/solution pair is correct
         """
         # use the serializer to validate the solution; id is validated implicitly on DB lookup
-        return self.serializer_class(data={'id': id, 'solution': solution}).is_valid()
+        return self.serializer_class(data={"id": id, "solution": solution}).is_valid()
 
     def obtain(self):
         if self.kind is None:
@@ -63,54 +62,57 @@ class CaptchaWorkflowTestCase(DesecTestCase):
 
     def test_obtain(self):
         response = self.obtain()
-        self.assertContains(response, 'id', status_code=status.HTTP_201_CREATED)
-        self.assertContains(response, 'challenge', status_code=status.HTTP_201_CREATED)
-        self.assertTrue('content' not in response.data)
+        self.assertContains(response, "id", status_code=status.HTTP_201_CREATED)
+        self.assertContains(response, "challenge", status_code=status.HTTP_201_CREATED)
+        self.assertTrue("content" not in response.data)
         self.assertTrue(len(response.data) == 3)
         self.assertEqual(self.captcha_class.objects.all().count(), 1)
         # use the value of f'<img src="data:image/png;base64,{response.data["challenge"].decode()}" />'
         # to display the CAPTCHA in a browser
 
     def test_verify_correct(self):
-        id = self.obtain().data['id']
+        id = self.obtain().data["id"]
         correct_solution = Captcha.objects.get(id=id).content
         self.assertTrue(self.verify(id, correct_solution))
 
     def test_verify_incorrect(self):
-        id = self.obtain().data['id']
-        wrong_solution = 'most certainly wrong!'
+        id = self.obtain().data["id"]
+        wrong_solution = "most certainly wrong!"
         self.assertFalse(self.verify(id, wrong_solution))
 
     def test_expired(self):
-        id = self.obtain().data['id']
+        id = self.obtain().data["id"]
         correct_solution = Captcha.objects.get(id=id).content
 
-        with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + settings.CAPTCHA_VALIDITY_PERIOD):
+        with mock.patch(
+            "desecapi.models.timezone.now",
+            return_value=timezone.now() + settings.CAPTCHA_VALIDITY_PERIOD,
+        ):
             self.assertFalse(self.verify(id, correct_solution))
 
 
 class ImageCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
-    kind = 'image'
+    kind = "image"
 
     def test_length(self):
-        self.assertTrue(5000 < len(self.obtain().data['challenge']) < 50000)
+        self.assertTrue(5000 < len(self.obtain().data["challenge"]) < 50000)
 
     def test_parses(self):
         for _ in range(10):
             # use the show method on the Image object to see the actual image during test run
             # This also allows an impression of how the CAPTCHAs will look like.
             cap = self.obtain().data
-            challenge = b64decode(cap['challenge'])
+            challenge = b64decode(cap["challenge"])
             Image.open(BytesIO(challenge))  # .show()
 
 
 class AudioCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
-    kind = 'audio'
+    kind = "audio"
 
     def test_length(self):
-        self.assertTrue(10**5 < len(self.obtain().data['challenge']) < 10**6)
+        self.assertTrue(10 ** 5 < len(self.obtain().data["challenge"]) < 10 ** 6)
 
     def test_parses(self):
         for _ in range(10):
-            challenge = b64decode(self.obtain().data['challenge'])
-            self.assertTrue(b'WAVE' in challenge)
+            challenge = b64decode(self.obtain().data["challenge"])
+            self.assertTrue(b"WAVE" in challenge)

+ 46 - 16
api/desecapi/tests/test_chores.py

@@ -11,40 +11,70 @@ from desecapi.models import Captcha, User
 class ChoresCommandTest(TestCase):
     @override_settings(CAPTCHA_VALIDITY_PERIOD=timezone.timedelta(hours=1))
     def test_captcha_cleanup(self):
-        faketime = timezone.now() - settings.CAPTCHA_VALIDITY_PERIOD - timezone.timedelta(seconds=1)
-        with mock.patch('django.db.models.fields.timezone.now', return_value=faketime):
+        faketime = (
+            timezone.now()
+            - settings.CAPTCHA_VALIDITY_PERIOD
+            - timezone.timedelta(seconds=1)
+        )
+        with mock.patch("django.db.models.fields.timezone.now", return_value=faketime):
             captcha1 = Captcha.objects.create()
 
         captcha2 = Captcha.objects.create()
-        self.assertGreaterEqual((captcha2.created - captcha1.created).total_seconds(), 3601)
+        self.assertGreaterEqual(
+            (captcha2.created - captcha1.created).total_seconds(), 3601
+        )
 
-        management.call_command('chores')
+        management.call_command("chores")
         self.assertEqual(list(Captcha.objects.all()), [captcha2])
 
-    @override_settings(VALIDITY_PERIOD_VERIFICATION_SIGNATURE=timezone.timedelta(hours=1))
+    @override_settings(
+        VALIDITY_PERIOD_VERIFICATION_SIGNATURE=timezone.timedelta(hours=1)
+    )
     def test_inactive_user_cleanup(self):
         def create_users(kind):
             logintime = timezone.now() + timezone.timedelta(seconds=5)
             kwargs_list = [
-                dict(email=f'user1+{kind}@example.com', is_active=None, last_login=None),
-                dict(email=f'user2+{kind}@example.com', is_active=None, last_login=logintime),
-                dict(email=f'user3+{kind}@example.com', is_active=False, last_login=None),
-                dict(email=f'user4+{kind}@example.com', is_active=False, last_login=logintime),
-                dict(email=f'user5+{kind}@example.com', is_active=True, last_login=None),
-                dict(email=f'user6+{kind}@example.com', is_active=True, last_login=logintime),
+                dict(
+                    email=f"user1+{kind}@example.com", is_active=None, last_login=None
+                ),
+                dict(
+                    email=f"user2+{kind}@example.com",
+                    is_active=None,
+                    last_login=logintime,
+                ),
+                dict(
+                    email=f"user3+{kind}@example.com", is_active=False, last_login=None
+                ),
+                dict(
+                    email=f"user4+{kind}@example.com",
+                    is_active=False,
+                    last_login=logintime,
+                ),
+                dict(
+                    email=f"user5+{kind}@example.com", is_active=True, last_login=None
+                ),
+                dict(
+                    email=f"user6+{kind}@example.com",
+                    is_active=True,
+                    last_login=logintime,
+                ),
             ]
             return (User.objects.create(**kwargs) for kwargs in kwargs_list)
 
         # Old users
-        faketime = timezone.now() - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE - timezone.timedelta(seconds=1)
-        with mock.patch('django.db.models.fields.timezone.now', return_value=faketime):
-            expired_user, *_ = create_users('old')
+        faketime = (
+            timezone.now()
+            - settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
+            - timezone.timedelta(seconds=1)
+        )
+        with mock.patch("django.db.models.fields.timezone.now", return_value=faketime):
+            expired_user, *_ = create_users("old")
 
         # New users
-        create_users('new')
+        create_users("new")
 
         all_users = set(User.objects.all())
 
-        management.call_command('chores')
+        management.call_command("chores")
         # Check that only the expired user was deleted
         self.assertEqual(all_users - set(User.objects.all()), {expired_user})

+ 21 - 11
api/desecapi/tests/test_crypto.py

@@ -7,25 +7,33 @@ from desecapi import crypto
 
 
 class CryptoTestCase(TestCase):
-    context = 'desecapi.tests.test_crypto'
+    context = "desecapi.tests.test_crypto"
 
     def test_retrieved_key_is_reproducible(self):
-        keys = (crypto.retrieve_key(label='test', context=self.context) for _ in range(2))
+        keys = (
+            crypto.retrieve_key(label="test", context=self.context) for _ in range(2)
+        )
         self.assertEqual(*keys)
 
     def test_retrieved_key_depends_on_secret(self):
         keys = []
-        for secret in ['abcdefgh', 'hgfedcba']:
+        for secret in ["abcdefgh", "hgfedcba"]:
             with self.settings(SECRET_KEY=secret):
-                keys.append(crypto.retrieve_key(label='test', context=self.context))
+                keys.append(crypto.retrieve_key(label="test", context=self.context))
         self.assertNotEqual(*keys)
 
     def test_retrieved_key_depends_on_label(self):
-        keys = (crypto.retrieve_key(label=f'test_{i}', context=self.context) for i in range(2))
+        keys = (
+            crypto.retrieve_key(label=f"test_{i}", context=self.context)
+            for i in range(2)
+        )
         self.assertNotEqual(*keys)
 
     def test_retrieved_key_depends_on_context(self):
-        keys = (crypto.retrieve_key(label='test', context=f'{self.context}_{i}') for i in range(2))
+        keys = (
+            crypto.retrieve_key(label="test", context=f"{self.context}_{i}")
+            for i in range(2)
+        )
         self.assertNotEqual(*keys)
 
     def test_encrypt_has_high_entropy(self):
@@ -37,23 +45,25 @@ class CryptoTestCase(TestCase):
                 result -= count * log(count, 2)
             return result * len(value)
 
-        ciphertext = crypto.encrypt(b'test', context=self.context)
+        ciphertext = crypto.encrypt(b"test", context=self.context)
         self.assertGreater(entropy(ciphertext), 100)  # arbitrary
 
     def test_encrypt_decrypt(self):
-        plain = b'test'
+        plain = b"test"
         ciphertext = crypto.encrypt(plain, context=self.context)
         timestamp, decrypted = crypto.decrypt(ciphertext, context=self.context)
         self.assertEqual(plain, decrypted)
         self.assertTrue(0 <= time.time() - timestamp <= 1)
 
     def test_encrypt_decrypt_raises_on_tampering(self):
-        ciphertext = crypto.encrypt(b'test', context=self.context)
+        ciphertext = crypto.encrypt(b"test", context=self.context)
 
         with self.assertRaises(ValueError):
             ciphertext_decoded = ciphertext.decode()
-            ciphertext_tampered = (ciphertext_decoded[:30] + 'TAMPERBEEF' + ciphertext_decoded[40:]).encode()
+            ciphertext_tampered = (
+                ciphertext_decoded[:30] + "TAMPERBEEF" + ciphertext_decoded[40:]
+            ).encode()
             crypto.decrypt(ciphertext_tampered, context=self.context)
 
         with self.assertRaises(ValueError):
-            crypto.decrypt(ciphertext, context=f'{self.context}2')
+            crypto.decrypt(ciphertext, context=f"{self.context}2")

文件差異過大導致無法顯示
+ 397 - 231
api/desecapi/tests/test_domains.py


+ 28 - 25
api/desecapi/tests/test_donations.py

@@ -6,18 +6,17 @@ from desecapi.tests.base import DesecTestCase
 
 
 class DonationTests(DesecTestCase):
-
     def test_unauthorized_access(self):
         for method in [self.client.get, self.client.put, self.client.delete]:
-            response = method(reverse('v1:donation'))
+            response = method(reverse("v1:donation"))
             self.assertStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
 
     def test_create_donation_minimal(self):
-        url = reverse('v1:donation')
+        url = reverse("v1:donation")
         data = {
-            'name': 'Name',
-            'iban': 'DE89370400440532013000',
-            'amount': 123.45,
+            "name": "Name",
+            "iban": "DE89370400440532013000",
+            "amount": 123.45,
         }
         response = self.client.post(url, data)
         self.assertTrue(mail.outbox)
@@ -25,24 +24,26 @@ class DonationTests(DesecTestCase):
         direct_debit = str(mail.outbox[0].attachments[0][1])
         reply_to = mail.outbox[0].reply_to
         self.assertStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(response.data.keys(), {'name', 'amount', 'email', 'mref', 'interval'})
+        self.assertEqual(
+            response.data.keys(), {"name", "amount", "email", "mref", "interval"}
+        )
         self.assertEqual(len(mail.outbox), 1)
-        self.assertEqual(response.data['interval'], 0)
-        self.assertIn('ONDON1', response.data['mref'])
-        self.assertTrue('Name' in direct_debit)
-        self.assertTrue(data['iban'] in email_internal)
+        self.assertEqual(response.data["interval"], 0)
+        self.assertIn("ONDON1", response.data["mref"])
+        self.assertTrue("Name" in direct_debit)
+        self.assertTrue(data["iban"] in email_internal)
         self.assertEqual(reply_to, [])
 
     def test_create_donation_verbose(self):
-        url = reverse('v1:donation')
+        url = reverse("v1:donation")
         data = {
-            'name': 'Komplizierter Vörnämü-ßßß 马大为',
-            'iban': 'DE89370400440532013000',
-            'bic': 'BYLADEM1SWU',
-            'amount': 123.45,
-            'message': 'hi there, thank you. Also, some random chars:  ™ • ½ ¼ ¾ ⅓ ⅔ † ‡ µ ¢ £ € « » ♤ ♧ ♥ ♢ ¿ ',
-            'email': 'email@example.com',
-            'interval': 3,
+            "name": "Komplizierter Vörnämü-ßßß 马大为",
+            "iban": "DE89370400440532013000",
+            "bic": "BYLADEM1SWU",
+            "amount": 123.45,
+            "message": "hi there, thank you. Also, some random chars:  ™ • ½ ¼ ¾ ⅓ ⅔ † ‡ µ ¢ £ € « » ♤ ♧ ♥ ♢ ¿ ",
+            "email": "email@example.com",
+            "interval": 3,
         }
         response = self.client.post(url, data)
         self.assertTrue(mail.outbox)
@@ -50,10 +51,12 @@ class DonationTests(DesecTestCase):
         direct_debit = str(mail.outbox[0].attachments[0][1])
         reply_to = mail.outbox[0].reply_to
         self.assertStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(response.data.keys(), {'name', 'amount', 'email', 'mref', 'interval'})
+        self.assertEqual(
+            response.data.keys(), {"name", "amount", "email", "mref", "interval"}
+        )
         self.assertEqual(len(mail.outbox), 2)
-        self.assertEqual(response.data['interval'], 3)
-        self.assertIn('ONDON1', response.data['mref'])
-        self.assertTrue('Komplizierter Vornamu' in direct_debit)
-        self.assertTrue(data['iban'] in email_internal)
-        self.assertEqual(reply_to, [data['email']])
+        self.assertEqual(response.data["interval"], 3)
+        self.assertIn("ONDON1", response.data["mref"])
+        self.assertTrue("Komplizierter Vornamu" in direct_debit)
+        self.assertTrue(data["iban"] in email_internal)
+        self.assertEqual(reply_to, [data["email"]])

+ 112 - 91
api/desecapi/tests/test_dyndns12update.py

@@ -6,45 +6,52 @@ from desecapi.tests.base import DynDomainOwnerTestCase
 
 
 class DynDNS12UpdateTest(DynDomainOwnerTestCase):
-
-    def assertIP(self, ipv4=None, ipv6=None, name=None, subname=''):
+    def assertIP(self, ipv4=None, ipv6=None, name=None, subname=""):
         name = name or self.my_domain.name.lower()
-        for type_, value in [('A', ipv4), ('AAAA', ipv6)]:
-            url = self.reverse('v1:rrset', name=name, subname=subname, type=type_)
+        for type_, value in [("A", ipv4), ("AAAA", ipv6)]:
+            url = self.reverse("v1:rrset", name=name, subname=subname, type=type_)
             response = self.client_token_authorized.get(url)
             if value:
                 self.assertStatus(response, status.HTTP_200_OK)
-                self.assertEqual(response.data['records'][0], value)
-                self.assertEqual(response.data['ttl'], 60)
+                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):
-        self.client.set_credentials_basic_auth(self.my_domain.name + '.invalid', self.token.plain)
-        response = self.assertDynDNS12NoUpdate(mock_remote_addr='10.5.5.6')
+        self.client.set_credentials_basic_auth(
+            self.my_domain.name + ".invalid", self.token.plain
+        )
+        response = self.assertDynDNS12NoUpdate(mock_remote_addr="10.5.5.6")
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
 
     def test_identification_by_query_params(self):
         # /update?username=foobar.dedyn.io&password=secret
         self.client.set_credentials_basic_auth(None, None)
-        response = self.assertDynDNS12Update(username=self.my_domain.name, password=self.token.plain)
+        response = self.assertDynDNS12Update(
+            username=self.my_domain.name, password=self.token.plain
+        )
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertEqual(response.content_type, 'text/plain')
-        self.assertIP(ipv4='127.0.0.1')
+        self.assertEqual(response.data, "good")
+        self.assertEqual(response.content_type, "text/plain")
+        self.assertIP(ipv4="127.0.0.1")
 
     def test_identification_by_query_params_with_subdomain(self):
         # /update?username=baz.foobar.dedyn.io&password=secret
         self.client.set_credentials_basic_auth(None, None)
-        response = self.assertDynDNS12NoUpdate(username='baz', password=self.token.plain)
+        response = self.assertDynDNS12NoUpdate(
+            username="baz", password=self.token.plain
+        )
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
-        self.assertEqual(response.content, b'badauth')
+        self.assertEqual(response.content, b"badauth")
 
-        for subname in ['baz', '*.baz']:
-            response = self.assertDynDNS12Update(username=f'{subname}.{self.my_domain.name}', password=self.token.plain)
+        for subname in ["baz", "*.baz"]:
+            response = self.assertDynDNS12Update(
+                username=f"{subname}.{self.my_domain.name}", password=self.token.plain
+            )
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data, 'good')
-            self.assertIP(ipv4='127.0.0.1', subname=subname)
+            self.assertEqual(response.data, "good")
+            self.assertIP(ipv4="127.0.0.1", subname=subname)
 
     def test_deviant_ttl(self):
         """
@@ -56,44 +63,46 @@ class DynDNS12UpdateTest(DynDomainOwnerTestCase):
             self.request_pdns_zone_update(self.my_domain.name),
             self.request_pdns_zone_axfr(self.my_domain.name),
         ):
-            response = self.client_token_authorized.patch_rr_set(self.my_domain.name.lower(), '', 'A', {'ttl': 3600})
+            response = self.client_token_authorized.patch_rr_set(
+                self.my_domain.name.lower(), "", "A", {"ttl": 3600}
+            )
             self.assertStatus(response, status.HTTP_200_OK)
 
         response = self.assertDynDNS12Update(self.my_domain.name)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1")
 
     def test_ddclient_dyndns1_v4_success(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
         with self.assertPdnsRequests(
-                self.request_pdns_zone_update(self.my_domain.name),
-                self.request_pdns_zone_axfr(self.my_domain.name),
+            self.request_pdns_zone_update(self.my_domain.name),
+            self.request_pdns_zone_axfr(self.my_domain.name),
         ):
             response = self.client.get(
-                self.reverse('v1:dyndns12update'),
+                self.reverse("v1:dyndns12update"),
                 {
-                    'action': 'edit',
-                    'started': 1,
-                    'hostname': 'YES',
-                    'host_id': self.my_domain.name,
-                    'myip': '10.1.2.3'
-                }
+                    "action": "edit",
+                    "started": 1,
+                    "hostname": "YES",
+                    "host_id": self.my_domain.name,
+                    "myip": "10.1.2.3",
+                },
             )
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data, 'good')
-            self.assertIP(ipv4='10.1.2.3')
+            self.assertEqual(response.data, "good")
+            self.assertIP(ipv4="10.1.2.3")
 
         # Repeat and make sure that no pdns request is made (not even for the empty AAAA record)
         response = self.client.get(
-            self.reverse('v1:dyndns12update'),
+            self.reverse("v1:dyndns12update"),
             {
-                'action': 'edit',
-                'started': 1,
-                'hostname': 'YES',
-                'host_id': self.my_domain.name,
-                'myip': '10.1.2.3'
-            }
+                "action": "edit",
+                "started": 1,
+                "hostname": "YES",
+                "host_id": self.my_domain.name,
+                "myip": "10.1.2.3",
+            },
         )
         self.assertStatus(response, status.HTTP_200_OK)
 
@@ -101,27 +110,27 @@ class DynDNS12UpdateTest(DynDomainOwnerTestCase):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
         response = self.assertDynDNS12Update(
             domain_name=self.my_domain.name,
-            action='edit',
+            action="edit",
             started=1,
-            hostname='YES',
+            hostname="YES",
             host_id=self.my_domain.name,
-            myipv6='::1337'
+            myipv6="::1337",
         )
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1', ipv6='::1337')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1", ipv6="::1337")
 
         # Repeat and make sure that no pdns request is made (not even for the empty A record)
         response = self.client.get(
-            self.reverse('v1:dyndns12update'),
+            self.reverse("v1:dyndns12update"),
             {
-                'domain_name': self.my_domain.name,
-                'action': 'edit',
-                'started': 1,
-                'hostname': 'YES',
-                'host_id': self.my_domain.name,
-                'myipv6': '::1337'
-        }
+                "domain_name": self.my_domain.name,
+                "action": "edit",
+                "started": 1,
+                "hostname": "YES",
+                "host_id": self.my_domain.name,
+                "myipv6": "::1337",
+            },
         )
         self.assertStatus(response, status.HTTP_200_OK)
 
@@ -129,66 +138,70 @@ class DynDNS12UpdateTest(DynDomainOwnerTestCase):
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
         response = self.assertDynDNS12Update(
             domain_name=self.my_domain.name,
-            system='dyndns',
+            system="dyndns",
             hostname=self.my_domain.name,
-            myip='10.2.3.4',
+            myip="10.2.3.4",
         )
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='10.2.3.4')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="10.2.3.4")
 
     def test_ddclient_dyndns2_v4_invalid(self):
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4asdf
         params = {
-            'domain_name': self.my_domain.name,
-            'system': 'dyndns',
-            'hostname': self.my_domain.name,
-            'myip': '10.2.3.4asdf',
+            "domain_name": self.my_domain.name,
+            "system": "dyndns",
+            "hostname": self.my_domain.name,
+            "myip": "10.2.3.4asdf",
         }
-        response = self.client.get(self.reverse('v1:dyndns12update'), params)
+        response = self.client.get(self.reverse("v1:dyndns12update"), params)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertIn('malformed', str(response.data))
+        self.assertIn("malformed", str(response.data))
 
     def test_ddclient_dyndns2_v4_invalid_or_foreign_domain(self):
         # /nic/update?system=dyndns&hostname=<...>&myip=10.2.3.4
-        for name in [self.owner.email, self.other_domain.name, self.my_domain.parent_domain_name]:
+        for name in [
+            self.owner.email,
+            self.other_domain.name,
+            self.my_domain.parent_domain_name,
+        ]:
             response = self.assertDynDNS12NoUpdate(
-                system='dyndns',
+                system="dyndns",
                 hostname=name,
-                myip='10.2.3.4',
+                myip="10.2.3.4",
             )
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
-            self.assertEqual(response.content, b'nohost')
+            self.assertEqual(response.content, b"nohost")
 
     def test_ddclient_dyndns2_v6_success(self):
         # /nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
         response = self.assertDynDNS12Update(
             domain_name=self.my_domain.name,
-            system='dyndns',
+            system="dyndns",
             hostname=self.my_domain.name,
-            myipv6='::666',
+            myipv6="::666",
         )
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1', ipv6='::666')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1", ipv6="::666")
 
     def test_fritz_box(self):
         # /
         response = self.assertDynDNS12Update(self.my_domain.name)
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="127.0.0.1")
 
     def test_unset_ip(self):
         for (v4, v6) in [
-            ('127.0.0.1', '::1'),
-            ('127.0.0.1', ''),
-            ('', '::1'),
-            ('', ''),
+            ("127.0.0.1", "::1"),
+            ("127.0.0.1", ""),
+            ("", "::1"),
+            ("", ""),
         ]:
             response = self.assertDynDNS12Update(self.my_domain.name, ip=v4, ipv6=v6)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(response.data, 'good')
+            self.assertEqual(response.data, "good")
             self.assertIP(ipv4=v4, ipv6=v6)
 
 
@@ -196,18 +209,22 @@ class SingleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
     NUM_OWNED_DOMAINS = 1
 
     def test_identification_by_token(self):
-        self.client.set_credentials_basic_auth('', self.token.plain)
-        response = self.assertDynDNS12Update(self.my_domain.name, mock_remote_addr='10.5.5.6')
+        self.client.set_credentials_basic_auth("", self.token.plain)
+        response = self.assertDynDNS12Update(
+            self.my_domain.name, mock_remote_addr="10.5.5.6"
+        )
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='10.5.5.6')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="10.5.5.6")
 
     def test_identification_by_email(self):
         self.client.set_credentials_basic_auth(self.owner.email, self.token.plain)
-        response = self.assertDynDNS12Update(self.my_domain.name, mock_remote_addr='10.5.5.6')
+        response = self.assertDynDNS12Update(
+            self.my_domain.name, mock_remote_addr="10.5.5.6"
+        )
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='10.5.5.6')
+        self.assertEqual(response.data, "good")
+        self.assertIP(ipv4="10.5.5.6")
 
 
 class MultipleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
@@ -220,25 +237,30 @@ class MultipleDomainDynDNS12UpdateTest(DynDNS12UpdateTest):
         # Test that dynDNS updates work both under a local public suffix (self.my_domain) and for a custom domains
         for domain in [self.my_domain, self.create_domain(owner=self.owner)]:
             self.assertGreater(domain.minimum_ttl, 60)
-            self.client.set_credentials_basic_auth(domain.name.lower(), self.token.plain)
+            self.client.set_credentials_basic_auth(
+                domain.name.lower(), self.token.plain
+            )
             response = self.assertDynDNS12Update(domain.name)
             self.assertStatus(response, status.HTTP_200_OK)
-            self.assertEqual(domain.rrset_set.get(subname='', type='A').ttl, 60)
+            self.assertEqual(domain.rrset_set.get(subname="", type="A").ttl, 60)
 
     def test_identification_by_token(self):
         """
         Test if the conflict of having multiple domains, but not specifying which to update is correctly recognized.
         """
-        self.client.set_credentials_basic_auth('', self.token.plain)
-        response = self.client.get(self.reverse('v1:dyndns12update'), REMOTE_ADDR='10.5.5.7')
+        self.client.set_credentials_basic_auth("", self.token.plain)
+        response = self.client.get(
+            self.reverse("v1:dyndns12update"), REMOTE_ADDR="10.5.5.7"
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
 class MixedCaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
-
     @staticmethod
     def random_casing(s):
-        return ''.join([c.lower() if random.choice([True, False]) else c.upper() for c in s])
+        return "".join(
+            [c.lower() if random.choice([True, False]) else c.upper() for c in s]
+        )
 
     def setUp(self):
         super().setUp()
@@ -246,7 +268,6 @@ class MixedCaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
 
 
 class UppercaseDynDNS12UpdateTestCase(DynDNS12UpdateTest):
-
     def setUp(self):
         super().setUp()
         self.my_domain.name = self.my_domain.name.upper()

+ 16 - 8
api/desecapi/tests/test_limit.py

@@ -6,29 +6,37 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 class LimitCommandTest(DomainOwnerTestCase):
-
     def test_update_domains(self):
-        management.call_command('limit', 'domains', self.owner.email, '123')
+        management.call_command("limit", "domains", self.owner.email, "123")
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.limit_domains, 123)
-        management.call_command('limit', 'domains', self.owner.email, 567)
+        management.call_command("limit", "domains", self.owner.email, 567)
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.limit_domains, 567)
-        management.call_command('limit', 'domains', self.owner.email, '1')  # below the actual number of domains
+        management.call_command(
+            "limit", "domains", self.owner.email, "1"
+        )  # below the actual number of domains
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.limit_domains, 1)
         # did not delete domains below limit:
         self.assertEqual(Domain.objects.filter(owner_id=self.owner.id).count(), 2)
 
     def test_update_minimum_ttl(self):
-        management.call_command('limit', 'ttl', self.my_domain.name, '123')
+        management.call_command("limit", "ttl", self.my_domain.name, "123")
         self.my_domain.refresh_from_db()
         self.assertEqual(self.my_domain.minimum_ttl, 123)
-        management.call_command('limit', 'ttl', self.my_domain.name, 567)
+        management.call_command("limit", "ttl", self.my_domain.name, 567)
         self.my_domain.refresh_from_db()
         self.assertEqual(self.my_domain.minimum_ttl, 567)
-        management.call_command('limit', 'ttl', self.my_domain.name, '10000')  # above the currently used ttl
+        management.call_command(
+            "limit", "ttl", self.my_domain.name, "10000"
+        )  # above the currently used ttl
         self.my_domain.refresh_from_db()
         self.assertEqual(self.my_domain.minimum_ttl, 10000)
         # did not change existing TTLs in violation of minimum TTL:
-        self.assertLess(RRset.objects.filter(domain_id=self.my_domain.id).aggregate(Min('ttl'))['ttl__min'], 10000)
+        self.assertLess(
+            RRset.objects.filter(domain_id=self.my_domain.id).aggregate(Min("ttl"))[
+                "ttl__min"
+            ],
+            10000,
+        )

+ 25 - 11
api/desecapi/tests/test_mail_backends.py

@@ -8,23 +8,37 @@ from django.test import TestCase
 from desecapi import mail_backends
 
 
-@mock.patch.dict(mail_backends.TASKS,
-                 {key: type('obj', (object,), {'delay': mail_backends.MultiLaneEmailBackend._run_task})
-                  for key in mail_backends.TASKS})
+@mock.patch.dict(
+    mail_backends.TASKS,
+    {
+        key: type(
+            "obj", (object,), {"delay": mail_backends.MultiLaneEmailBackend._run_task}
+        )
+        for key in mail_backends.TASKS
+    },
+)
 class MultiLaneEmailBackendTestCase(TestCase):
     test_backend = settings.EMAIL_BACKEND
 
     def test_lanes(self):
-        debug_params = {'foo': 'bar'}
+        debug_params = {"foo": "bar"}
         debug_params_orig = debug_params.copy()
 
-        with self.settings(EMAIL_BACKEND='desecapi.mail_backends.MultiLaneEmailBackend'):
-            for lane in ['email_slow_lane', 'email_fast_lane', None]:
-                subject = f'Test subject for lane {lane}'
-                connection = get_connection(lane=lane, backbackend=self.test_backend, debug=debug_params)
-                EmailMessage(subject=subject, to=['to@test.invalid'], connection=connection).send()
-                self.assertEqual(mail.outbox[-1].connection.task_kwargs['debug'],
-                                 {'lane': lane or 'email_slow_lane', **debug_params})
+        with self.settings(
+            EMAIL_BACKEND="desecapi.mail_backends.MultiLaneEmailBackend"
+        ):
+            for lane in ["email_slow_lane", "email_fast_lane", None]:
+                subject = f"Test subject for lane {lane}"
+                connection = get_connection(
+                    lane=lane, backbackend=self.test_backend, debug=debug_params
+                )
+                EmailMessage(
+                    subject=subject, to=["to@test.invalid"], connection=connection
+                ).send()
+                self.assertEqual(
+                    mail.outbox[-1].connection.task_kwargs["debug"],
+                    {"lane": lane or "email_slow_lane", **debug_params},
+                )
                 self.assertEqual(mail.outbox[-1].subject, subject)
 
         # Check that the backend hasn't modified the dict we passed

+ 245 - 125
api/desecapi/tests/test_pdns_change_tracker.py

@@ -14,9 +14,15 @@ class PdnsChangeTrackerTestCase(DesecTestCase):
     @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())
+        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(
@@ -29,7 +35,7 @@ class PdnsChangeTrackerTestCase(DesecTestCase):
     def test_rrset_does_not_exist_exception(self):
         tracker = PDNSChangeTracker()
         tracker.__enter__()
-        tracker._rr_set_updated(RRset(domain=self.empty_domain, subname='', type='A'))
+        tracker._rr_set_updated(RRset(domain=self.empty_domain, subname="", type="A"))
         with self.assertRaises(ValueError):
             tracker.__exit__(None, None, None)
 
@@ -38,13 +44,14 @@ 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'
+    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']
+    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):
@@ -52,7 +59,9 @@ class RRTestCase(PdnsChangeTrackerTestCase):
 
         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.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])
@@ -130,20 +139,30 @@ class RRTestCase(PdnsChangeTrackerTestCase):
 
     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 = 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 = 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])
+            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():
@@ -166,28 +185,38 @@ class RRTestCase(PdnsChangeTrackerTestCase):
 
     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 = 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):
+            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 = 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.objects.create(
+                rrset=self.simple_rr_set, content=self.ALT_CONTENT_VALUES[0]
+            )
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.save()
 
@@ -225,7 +254,9 @@ class RRTestCase(PdnsChangeTrackerTestCase):
     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 = 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])
@@ -235,7 +266,9 @@ class RRTestCase(PdnsChangeTrackerTestCase):
         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 = RR.objects.create(
+                rrset=self.simple_rr_set, content=self.CONTENT_VALUES[1]
+            )
             rr.content = self.ALT_CONTENT_VALUES[1]
             rr.save()
 
@@ -245,51 +278,83 @@ class RRTestCase(PdnsChangeTrackerTestCase):
             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])
+            RR.objects.create(
+                rrset=self.full_rr_set, content=self.ALT_CONTENT_VALUES[1]
+            )
 
 
 class AAAARRTestCase(RRTestCase):
-    SUBNAME = '*.foobar'
-    TYPE = 'AAAA'
+    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']
+    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'
+    SUBNAME = "_acme_challenge"
+    TYPE = "TXT"
     TTL = 876
-    CONTENT_VALUES = ['"The quick brown fox jumps over the lazy dog"',
-                      '"main( ) {printf(\\"hello, world\\010\\");}"',
-                      '"“红色联合”对“四·二八兵团”总部大楼的攻击已持续了两天"']
-    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"']
+    CONTENT_VALUES = [
+        '"The quick brown fox jumps over the lazy dog"',
+        '"main( ) {printf(\\"hello, world\\010\\");}"',
+        '"“红色联合”对“四·二八兵团”总部大楼的攻击已持续了两天"',
+    ]
+    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'],
+        ("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'],
+        ("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
@@ -314,15 +379,17 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
 
     def test_empty_domain_create_single_empty(self):
         with PDNSChangeTracker():
-            RRset.objects.create(domain=self.empty_domain, subname='', ttl=60, type='A')
+            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():
+        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')
+            RRset.objects.create(domain=self.full_domain, subname="", ttl=60, type="A")
 
     def test_empty_domain_create_many_empty(self):
         with PDNSChangeTracker():
@@ -330,7 +397,9 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
             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():
+        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):
@@ -341,20 +410,29 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
 
     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():
+        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():
+        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_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():
+        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()
@@ -362,84 +440,107 @@ class RRSetTestCase(PdnsChangeTrackerTestCase):
     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():
+        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():
+        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():
+        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)]:
+        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()
+        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[("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()
+            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'],
+        ("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
@@ -449,31 +550,44 @@ class DomainTestCase(PdnsChangeTrackerTestCase):
 
     def setUp(self):
         super().setUp()
-        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.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)
+        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)
+        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)
+        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_update_catalog(),
-                    self.request_pdns_zone_axfr(name)
-                ]), PDNSChangeTracker():
+            [
+                self.request_pdns_zone_create("LORD"),
+                self.request_pdns_zone_create("MASTER"),
+                self.request_pdns_update_catalog(),
+                self.request_pdns_zone_axfr(name),
+            ]
+        ), PDNSChangeTracker():
             Domain.objects.create(name=name, owner=self.user)
 
     def test_update_domain(self):
@@ -491,13 +605,19 @@ class DomainTestCase(PdnsChangeTrackerTestCase):
 
     def test_delete_single(self):
         for domain in self.domains:
-            with self.assertPdnsRequests(self.requests_desec_domain_deletion(domain)), PDNSChangeTracker():
+            with self.assertPdnsRequests(
+                self.requests_desec_domain_deletion(domain)
+            ), PDNSChangeTracker():
                 domain.delete()
 
     def test_delete_multiple(self):
-        with self.assertPdnsRequests([
-            self.requests_desec_domain_deletion(domain) for domain in reversed(self.domains)
-        ], expect_order=False), PDNSChangeTracker():
+        with self.assertPdnsRequests(
+            [
+                self.requests_desec_domain_deletion(domain)
+                for domain in reversed(self.domains)
+            ],
+            expect_order=False,
+        ), PDNSChangeTracker():
             for domain in self.domains:
                 domain.delete()
 

+ 14 - 12
api/desecapi/tests/test_replication.py

@@ -7,26 +7,28 @@ from desecapi.tests.base import DesecTestCase
 
 class ReplicationTest(DesecTestCase):
     def test_serials(self):
-        url = self.reverse('v1:serial')
+        url = self.reverse("v1:serial")
         zones = [
-            {'name': 'test.example.', 'edited_serial': 12345},
-            {'name': 'example.org.', 'edited_serial': 54321},
+            {"name": "test.example.", "edited_serial": 12345},
+            {"name": "example.org.", "edited_serial": 54321},
+        ]
+        serials = {zone["name"]: zone["edited_serial"] for zone in zones}
+        pdns_requests = [
+            {
+                "method": "GET",
+                "uri": self.get_full_pdns_url(r"/zones", ns="MASTER"),
+                "status": 200,
+                "body": json.dumps(zones),
+            }
         ]
-        serials = {zone['name']: zone['edited_serial'] for zone in zones}
-        pdns_requests = [{
-            'method': 'GET',
-            'uri': self.get_full_pdns_url(r'/zones', ns='MASTER'),
-            'status': 200,
-            'body': json.dumps(zones),
-        }]
 
         # Run twice to make sure cache output varies on remote address
         for i in range(2):
-            response = self.client.get(path=url, REMOTE_ADDR='123.8.0.2')
+            response = self.client.get(path=url, REMOTE_ADDR="123.8.0.2")
             self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
 
             with self.assertPdnsRequests(pdns_requests):
-                response = self.client.get(path=url, REMOTE_ADDR='10.8.0.2')
+                response = self.client.get(path=url, REMOTE_ADDR="10.8.0.2")
             self.assertStatus(response, status.HTTP_200_OK)
             self.assertEqual(response.data, serials)
 

文件差異過大導致無法顯示
+ 607 - 287
api/desecapi/tests/test_rrsets.py


+ 463 - 185
api/desecapi/tests/test_rrsets_bulk.py

@@ -7,44 +7,57 @@ from desecapi.tests.base import AuthenticatedRRSetBaseTestCase
 
 
 class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
-
     @classmethod
     def setUpTestDataWithPdns(cls):
         super().setUpTestDataWithPdns()
 
         cls.data = [
-            {'subname': 'my-cname', 'records': ['example.com.'], 'ttl': 3600, 'type': 'CNAME'},
-            {'subname': 'my-bulk', 'records': ['desec.io.', 'foobar.example.'], 'ttl': 3600, 'type': 'PTR'},
+            {
+                "subname": "my-cname",
+                "records": ["example.com."],
+                "ttl": 3600,
+                "type": "CNAME",
+            },
+            {
+                "subname": "my-bulk",
+                "records": ["desec.io.", "foobar.example."],
+                "ttl": 3600,
+                "type": "PTR",
+            },
         ]
 
         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_empty_records[1]["records"] = []
 
         cls.data_no_subname = copy.deepcopy(cls.data)
-        cls.data_no_subname[0].pop('subname')
+        cls.data_no_subname[0].pop("subname")
 
         cls.data_no_ttl = copy.deepcopy(cls.data)
-        cls.data_no_ttl[0].pop('ttl')
+        cls.data_no_ttl[0].pop("ttl")
 
         cls.data_no_type = copy.deepcopy(cls.data)
-        cls.data_no_type[1].pop('type')
+        cls.data_no_type[1].pop("type")
 
         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.data_no_subname_empty_records[0]["records"] = []
 
         cls.bulk_domain = cls.create_domain(owner=cls.owner)
         for data in cls.data:
             cls.create_rr_set(cls.bulk_domain, **data)
 
     def test_bulk_post_my_rr_sets(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
-            response = self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)
+        ):
+            response = self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=self.data
+            )
             self.assertStatus(response, status.HTTP_201_CREATED)
 
         response = self.client.get_rr_sets(self.my_empty_domain.name)
@@ -53,26 +66,33 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
         # 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),
+            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.']}
-            ]
+                {"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)
+        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),
+            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.']}
-            ]
+            [{}, {"records": ["This field must not be empty when using POST."]}],
         )
 
     def test_bulk_post_existing_rrsets(self):
@@ -82,33 +102,62 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
                 payload=self.data,
             ),
             status.HTTP_400_BAD_REQUEST,
-            2 * [{
-                'non_field_errors': ['Another RRset with the same subdomain and type exists for this domain.']
-            }]
+            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),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=data
+            ),
             status.HTTP_400_BAD_REQUEST,
             [
-                {'non_field_errors': ['Same subname and type as in position(s) 1, but must be unique.']},
-                {'non_field_errors': ['Same subname and type as in position(s) 0, but must be unique.']},
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 1, but must be unique."
+                    ]
+                },
+                {
+                    "non_field_errors": [
+                        "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),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=data
+            ),
             status.HTTP_400_BAD_REQUEST,
             [
-                {'non_field_errors': ['Same subname and type as in position(s) 1, 3, but must be unique.']},
-                {'non_field_errors': ['Same subname and type as in position(s) 0, 3, but must be unique.']},
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 1, 3, but must be unique."
+                    ]
+                },
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 0, 3, but must be unique."
+                    ]
+                },
                 {},
-                {'non_field_errors': ['Same subname and type as in position(s) 0, 1, but must be unique.']},
-            ]
+                {
+                    "non_field_errors": [
+                        "Same subname and type as in position(s) 0, 1, but must be unique."
+                    ]
+                },
+            ],
         )
 
     def test_bulk_post_missing_fields(self):
@@ -116,125 +165,221 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
             self.client.bulk_post_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 payload=[
-                    {'subname': 'a.1', 'records': ['dead::beef'], 'ttl': 3622},
-                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
-                    {'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': '', 'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'SOA',
-                     'records': ['get.desec.io. get.desec.io. 2018034419 10800 3600 604800 60']},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'OPT', 'records': ['9999']},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'TYPE099', 'records': ['v=spf1 mx -all']},
-                ]
+                    {"subname": "a.1", "records": ["dead::beef"], "ttl": 3622},
+                    {
+                        "subname": "b.1",
+                        "ttl": -50,
+                        "type": "AAAA",
+                        "records": ["dead::beef"],
+                    },
+                    {"ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "", "ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "c.1", "records": ["dead::beef"], "type": "AAAA"},
+                    {"subname": "d.1", "ttl": 3650, "type": "AAAA"},
+                    {
+                        "subname": "d.1",
+                        "ttl": 3650,
+                        "type": "SOA",
+                        "records": [
+                            "get.desec.io. get.desec.io. 2018034419 10800 3600 604800 60"
+                        ],
+                    },
+                    {"subname": "d.1", "ttl": 3650, "type": "OPT", "records": ["9999"]},
+                    {
+                        "subname": "d.1",
+                        "ttl": 3650,
+                        "type": "TYPE099",
+                        "records": ["v=spf1 mx -all"],
+                    },
+                ],
             ),
             status.HTTP_400_BAD_REQUEST,
             [
-                {'type': ['This field is required.']},
-                {'ttl': [f'Ensure this value is greater than or equal to {self.my_empty_domain.minimum_ttl}.']},
-                {'subname': ['This field is required.']},
+                {"type": ["This field is required."]},
+                {
+                    "ttl": [
+                        f"Ensure this value is greater than or equal to {self.my_empty_domain.minimum_ttl}."
+                    ]
+                },
+                {"subname": ["This field is required."]},
                 {},
-                {'ttl': ['This field is required.']},
-                {'records': ['This field is required.']},
-                {'type': ['You cannot tinker with the SOA RR set. It is managed automatically.']},
-                {'type': ['You cannot tinker with the OPT RR set. It is managed automatically.']},
-                {'type': ['Generic type format is not supported.']},
-            ]
+                {"ttl": ["This field is required."]},
+                {"records": ["This field is required."]},
+                {
+                    "type": [
+                        "You cannot tinker with the SOA RR set. It is managed automatically."
+                    ]
+                },
+                {
+                    "type": [
+                        "You cannot tinker with the OPT RR set. It is managed automatically."
+                    ]
+                },
+                {"type": ["Generic type format is not supported."]},
+            ],
         )
 
     def test_bulk_patch_cname_exclusivity(self):
         response = self.client.bulk_patch_rr_sets(
             domain_name=self.my_rr_set_domain.name,
             payload=[
-                {'subname': 'test', 'type': 'A', 'ttl': 3600, 'records': ['1.2.3.4']},
-                {'subname': 'test', 'type': 'CNAME', 'ttl': 3600, 'records': ['example.com.']},
-            ]
+                {"subname": "test", "type": "A", "ttl": 3600, "records": ["1.2.3.4"]},
+                {
+                    "subname": "test",
+                    "type": "CNAME",
+                    "ttl": 3600,
+                    "records": ["example.com."],
+                },
+            ],
         )
         self.assertResponse(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.json(), [
-            {"non_field_errors":["RRset with conflicting type present: 1 (CNAME). (No other RRsets are allowed alongside CNAME.)"]},
-            {"non_field_errors":["RRset with conflicting type present: 0 (A), database (A, TXT). (No other RRsets are allowed alongside CNAME.)"]},
-        ])
+        self.assertEqual(
+            response.json(),
+            [
+                {
+                    "non_field_errors": [
+                        "RRset with conflicting type present: 1 (CNAME). (No other RRsets are allowed alongside CNAME.)"
+                    ]
+                },
+                {
+                    "non_field_errors": [
+                        "RRset with conflicting type present: 0 (A), database (A, TXT). (No other RRsets are allowed alongside CNAME.)"
+                    ]
+                },
+            ],
+        )
 
     def test_bulk_post_accepts_empty_list(self):
         self.assertResponse(
-            self.client.bulk_post_rr_sets(domain_name=self.my_empty_domain.name, payload=[]),
+            self.client.bulk_post_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=[]
+            ),
             status.HTTP_201_CREATED,
         )
 
     def test_bulk_patch_fresh_rrsets_need_records(self):
-        response = self.client.bulk_patch_rr_sets(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.assertEqual(response.data, [{}, {'records': ['This field is required.']}])
+        self.assertEqual(response.data, [{}, {"records": ["This field is required."]}])
 
-        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)
+        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)
 
     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)
+        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):
-        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.assertEqual(response.data, [{'ttl': ['This field is required.']}, {}])
+        self.assertEqual(response.data, [{"ttl": ["This field is required."]}, {}])
 
     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.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 items but got dict.', status_code=status.HTTP_400_BAD_REQUEST)
+        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 items but got dict.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
     def test_bulk_patch_does_accept_empty_list(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=[])
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
 
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_rr_set_domain.name, payload=[])
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_rr_set_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
 
     def test_bulk_patch_does_not_accept_empty_payload(self):
-        response = self.client.bulk_patch_rr_sets(domain_name=self.my_empty_domain.name, payload=None)
-        self.assertContains(response, 'No data provided', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_patch_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=None
+        )
+        self.assertContains(
+            response, "No data provided", status_code=status.HTTP_400_BAD_REQUEST
+        )
 
     def test_bulk_patch_cname_exclusivity_atomic_rrset_replacement(self):
-        self.create_rr_set(self.my_empty_domain, subname='test', type='A', records=['1.2.3.4'], ttl=3600)
+        self.create_rr_set(
+            self.my_empty_domain,
+            subname="test",
+            type="A",
+            records=["1.2.3.4"],
+            ttl=3600,
+        )
 
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(self.my_empty_domain.name)
+        ):
             response = self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 payload=[
-                    {'subname': 'test', 'type': 'CNAME', 'ttl': 3605, 'records': ['example.com.']},
-                    {'subname': 'test', 'type': 'A', 'records': []},
-                ]
+                    {
+                        "subname": "test",
+                        "type": "CNAME",
+                        "ttl": 3605,
+                        "records": ["example.com."],
+                    },
+                    {"subname": "test", "type": "A", "records": []},
+                ],
             )
             self.assertResponse(response, status.HTTP_200_OK)
             self.assertEqual(len(response.data), 1)
-            self.assertEqual(response.data[0]['type'], 'CNAME')
-            self.assertEqual(response.data[0]['records'], ['example.com.'])
-            self.assertEqual(response.data[0]['ttl'], 3605)
+            self.assertEqual(response.data[0]["type"], "CNAME")
+            self.assertEqual(response.data[0]["records"], ["example.com."])
+            self.assertEqual(response.data[0]["ttl"], 3605)
 
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(self.my_empty_domain.name)):
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(self.my_empty_domain.name)
+        ):
             response = self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 payload=[
-                    {'subname': 'test', 'type': 'CNAME', 'records': []},
-                    {'subname': 'test', 'type': 'A', 'ttl': 3600, 'records': ['5.4.2.1']},
-                ]
+                    {"subname": "test", "type": "CNAME", "records": []},
+                    {
+                        "subname": "test",
+                        "type": "A",
+                        "ttl": 3600,
+                        "records": ["5.4.2.1"],
+                    },
+                ],
             )
             self.assertResponse(response, status.HTTP_200_OK)
             self.assertEqual(len(response.data), 1)
-            self.assertEqual(response.data[0]['type'], 'A')
-            self.assertEqual(response.data[0]['records'], ['5.4.2.1'])
-            self.assertEqual(response.data[0]['ttl'], 3600)
+            self.assertEqual(response.data[0]["type"], "A")
+            self.assertEqual(response.data[0]["records"], ["5.4.2.1"])
+            self.assertEqual(response.data[0]["ttl"], 3600)
 
     def test_bulk_patch_full_on_empty_domain(self):
         # Full patch always works
-        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)
+        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
+            )
             self.assertStatus(response, status.HTTP_200_OK)
 
         # Check that RRsets have been created
@@ -244,9 +389,13 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
     def test_bulk_patch_change_records(self):
         data_no_ttl = copy.deepcopy(self.data_no_ttl)
-        data_no_ttl[0]['records'] = ['example.org.']
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.bulk_domain.name)):
-            response = self.client.bulk_patch_rr_sets(domain_name=self.bulk_domain.name, payload=data_no_ttl)
+        data_no_ttl[0]["records"] = ["example.org."]
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.bulk_domain.name)
+        ):
+            response = self.client.bulk_patch_rr_sets(
+                domain_name=self.bulk_domain.name, payload=data_no_ttl
+            )
             self.assertStatus(response, status.HTTP_200_OK)
 
         response = self.client.get_rr_sets(self.bulk_domain.name)
@@ -255,9 +404,13 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
     def test_bulk_patch_change_ttl(self):
         data_no_records = copy.deepcopy(self.data_no_records)
-        data_no_records[1]['ttl'] = 3911
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.bulk_domain.name)):
-            response = self.client.bulk_patch_rr_sets(domain_name=self.bulk_domain.name, payload=data_no_records)
+        data_no_records[1]["ttl"] = 3911
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.bulk_domain.name)
+        ):
+            response = self.client.bulk_patch_rr_sets(
+                domain_name=self.bulk_domain.name, payload=data_no_records
+            )
             self.assertStatus(response, status.HTTP_200_OK)
 
         response = self.client.get_rr_sets(self.bulk_domain.name)
@@ -266,7 +419,9 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
 
     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),
+            self.client.bulk_patch_rr_sets(
+                domain_name=self.bulk_domain.name, payload=self.data_no_ttl
+            ),
             status.HTTP_200_OK,
         )
 
@@ -275,135 +430,241 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
             self.client.bulk_patch_rr_sets(
                 domain_name=self.my_empty_domain.name,
                 payload=[
-                    {'subname': 'a', 'type': 'A', 'records': [], 'ttl': 3622},
-                    {'subname': 'b', 'type': 'AAAA', 'records': []},
-                ]),
+                    {"subname": "a", "type": "A", "records": [], "ttl": 3622},
+                    {"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)):
+        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': 3650, 'type': 'TXT', 'records': ['"foo"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA', 'ttl': 3603},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA', 'records': ['::1', '::2']},
-                ]
+                    {"subname": "", "ttl": 3650, "type": "TXT", "records": ['"foo"']},
+                    {
+                        "subname": "c.1",
+                        "records": ["dead::beef"],
+                        "type": "AAAA",
+                        "ttl": 3603,
+                    },
+                    {
+                        "subname": "d.1",
+                        "ttl": 3650,
+                        "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': 3622},
-                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
-                    {'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA'},
-                ]),
+                    {"subname": "a.1", "records": ["dead::beef"], "ttl": 3622},
+                    {
+                        "subname": "b.1",
+                        "ttl": -50,
+                        "type": "AAAA",
+                        "records": ["dead::beef"],
+                    },
+                    {"ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "c.1", "records": ["dead::beef"], "type": "AAAA"},
+                    {"subname": "d.1", "ttl": 3650, "type": "AAAA"},
+                ],
+            ),
             status.HTTP_400_BAD_REQUEST,
             [
-                {'type': ['This field is required.']},
-                {'ttl': [f'Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}.']},
-                {'subname': ['This field is required.']},
+                {"type": ["This field is required."]},
+                {
+                    "ttl": [
+                        f"Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}."
+                    ]
+                },
+                {"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)):
+        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': 3650, 'type': 'TXT', 'records': ['"foo"']}
-                ]
+                    {"subname": "", "ttl": 3650, "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': 3622},
-                    {'subname': 'b.1', 'ttl': -50, 'type': 'AAAA', 'records': ['dead::beef']},
-                    {'ttl': 3640, 'type': 'TXT', 'records': ['"bar"']},
-                    {'subname': 'c.1', 'records': ['dead::beef'], 'type': 'AAAA'},
-                    {'subname': 'd.1', 'ttl': 3650, 'type': 'AAAA'},
-                ]),
+                    {"subname": "a.1", "records": ["dead::beef"], "ttl": 3622},
+                    {
+                        "subname": "b.1",
+                        "ttl": -50,
+                        "type": "AAAA",
+                        "records": ["dead::beef"],
+                    },
+                    {"ttl": 3640, "type": "TXT", "records": ['"bar"']},
+                    {"subname": "c.1", "records": ["dead::beef"], "type": "AAAA"},
+                    {"subname": "d.1", "ttl": 3650, "type": "AAAA"},
+                ],
+            ),
             status.HTTP_400_BAD_REQUEST,
             [
-                {'type': ['This field is required.']},
-                {'ttl': [f'Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}.']},
-                {'subname': ['This field is required.']},
-                {'ttl': ['This field is required.']},
-                {'records': ['This field is required.']},
-            ]
+                {"type": ["This field is required."]},
+                {
+                    "ttl": [
+                        f"Ensure this value is greater than or equal to {settings.MINIMUM_TTL_DEFAULT}."
+                    ]
+                },
+                {"subname": ["This field is required."]},
+                {"ttl": ["This field is required."]},
+                {"records": ["This field is required."]},
+            ],
         )
 
     def test_bulk_put_partial(self):
         # Need all fields
         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.assertEqual(response.data, [{}, {'records': ['This field is required.']}])
+            self.assertEqual(
+                response.data, [{}, {"records": ["This field is required."]}]
+            )
 
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_ttl)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_ttl
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{'ttl': ['This field is required.']}, {}])
+            self.assertEqual(response.data, [{"ttl": ["This field is required."]}, {}])
 
-            response = self.client.bulk_put_rr_sets(domain_name=domain.name, payload=self.data_no_records_no_ttl)
+            response = self.client.bulk_put_rr_sets(
+                domain_name=domain.name, payload=self.data_no_records_no_ttl
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertEqual(response.data, [{},
-                                             {'ttl': ['This field is required.'],
-                                              'records': ['This field is required.']}])
+            self.assertEqual(
+                response.data,
+                [
+                    {},
+                    {
+                        "ttl": ["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)
+            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.']}, {}])
+            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)
+            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.']}, {}])
+            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.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 items but got dict.', status_code=status.HTTP_400_BAD_REQUEST)
+        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 items but got dict.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
     def test_bulk_put_does_accept_empty_list(self):
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=[])
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_rr_set_domain.name, payload=[])
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_rr_set_domain.name, payload=[]
+        )
         self.assertStatus(response, status.HTTP_200_OK)
 
     def test_bulk_put_does_not_accept_empty_payload(self):
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=None)
-        self.assertContains(response, 'No data provided', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=None
+        )
+        self.assertContains(
+            response, "No data provided", status_code=status.HTTP_400_BAD_REQUEST
+        )
 
     def test_bulk_put_does_not_accept_list_of_crap(self):
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=['bla'])
-        self.assertContains(response, 'Expected a dictionary, but got str.', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=["bla"]
+        )
+        self.assertContains(
+            response,
+            "Expected a dictionary, but got str.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=[42])
-        self.assertContains(response, 'Expected a dictionary, but got int.', status_code=status.HTTP_400_BAD_REQUEST)
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=[42]
+        )
+        self.assertContains(
+            response,
+            "Expected a dictionary, but got int.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
     def test_bulk_put_does_not_accept_rrsets_with_nonstr_subname(self):
-        payload = [{"subname": ["foobar"], "type": "A", "ttl": 3600, "records": ["1.2.3.4"]}]
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=payload)
-        self.assertContains(response, 'Expected a string, but got list.', status_code=status.HTTP_400_BAD_REQUEST)
+        payload = [
+            {"subname": ["foobar"], "type": "A", "ttl": 3600, "records": ["1.2.3.4"]}
+        ]
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=payload
+        )
+        self.assertContains(
+            response,
+            "Expected a string, but got list.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
     def test_bulk_put_does_not_accept_rrsets_with_nonstr_type(self):
-        payload = [{"subname": "foobar", "type": ["A"], "ttl": 3600, "records": ["1.2.3.4"]}]
-        response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=payload)
-        self.assertContains(response, 'Expected a string, but got list.', status_code=status.HTTP_400_BAD_REQUEST)
+        payload = [
+            {"subname": "foobar", "type": ["A"], "ttl": 3600, "records": ["1.2.3.4"]}
+        ]
+        response = self.client.bulk_put_rr_sets(
+            domain_name=self.my_empty_domain.name, payload=payload
+        )
+        self.assertContains(
+            response,
+            "Expected a string, but got list.",
+            status_code=status.HTTP_400_BAD_REQUEST,
+        )
 
     def test_bulk_put_full(self):
         # Full PUT always works
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)):
-            response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=self.data)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_empty_domain.name)
+        ):
+            response = self.client.bulk_put_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=self.data
+            )
             self.assertStatus(response, status.HTTP_200_OK)
 
         # Check that RRsets have been created
@@ -412,27 +673,37 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
         self.assertRRSetsCount(response.data, self.data)
 
         # Do not expect any updates, but successful code when PUT'ing only existing RRsets
-        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)
 
     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'),
+            "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"),
         ]:
-            payload = [{'subname': 'a.2', 'ttl': 3600, 'type': 'MX', 'records': records}]
-            response = self.client.bulk_put_rr_sets(domain_name=self.my_empty_domain.name, payload=payload)
+            payload = [
+                {"subname": "a.2", "ttl": 3600, "type": "MX", "records": records}
+            ]
+            response = self.client.bulk_put_rr_sets(
+                domain_name=self.my_empty_domain.name, payload=payload
+            )
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-            self.assertTrue('records' in response.data[0])
+            self.assertTrue("records" in response.data[0])
 
     def test_bulk_put_empty_records(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.bulk_domain.name)):
+        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
+                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):
@@ -442,19 +713,26 @@ class AuthenticatedRRSetBulkTestCase(AuthenticatedRRSetBaseTestCase):
             self.client.bulk_put_rr_sets,
             self.client.bulk_post_rr_sets,
         ]:
-            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)
 
     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, 'Expected a list of items but got dict.',
-                                status_code=status.HTTP_400_BAD_REQUEST)
+            response = method(
+                domain_name=self.my_empty_domain.name, payload=self.data[0]
+            )
+            self.assertContains(
+                response,
+                "Expected a list of items but got dict.",
+                status_code=status.HTTP_400_BAD_REQUEST,
+            )
 
     def test_bulk_delete_rrsets(self):
         self.assertStatus(
             self.client.delete(
-                self.reverse('v1:rrsets', name=self.my_empty_domain.name),
+                self.reverse("v1:rrsets", name=self.my_empty_domain.name),
                 data=None,
             ),
             status.HTTP_405_METHOD_NOT_ALLOWED,

+ 64 - 27
api/desecapi/tests/test_stop_abuse.py

@@ -6,27 +6,44 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 class StopAbuseCommandTest(DomainOwnerTestCase):
-
     @classmethod
     def setUpTestDataWithPdns(cls):
         super().setUpTestDataWithPdns()
-        cls.create_rr_set(cls.my_domains[1], ['127.0.0.1', '127.0.1.1'], type='A', ttl=123)
-        cls.create_rr_set(cls.other_domains[1], ['40.1.1.1', '40.2.2.2'], type='A', ttl=456)
+        cls.create_rr_set(
+            cls.my_domains[1], ["127.0.0.1", "127.0.1.1"], type="A", ttl=123
+        )
+        cls.create_rr_set(
+            cls.other_domains[1], ["40.1.1.1", "40.2.2.2"], type="A", ttl=456
+        )
         for d in cls.my_domains + cls.other_domains:
-            cls.create_rr_set(d, ['ns1.example.', 'ns2.example.'], type='NS', ttl=456)
-            cls.create_rr_set(d, ['ns1.example.', 'ns2.example.'], type='NS', ttl=456, subname='subname')
-            cls.create_rr_set(d, ['"foo"'], type='TXT', ttl=456)
+            cls.create_rr_set(d, ["ns1.example.", "ns2.example."], type="NS", ttl=456)
+            cls.create_rr_set(
+                d,
+                ["ns1.example.", "ns2.example."],
+                type="NS",
+                ttl=456,
+                subname="subname",
+            )
+            cls.create_rr_set(d, ['"foo"'], type="TXT", ttl=456)
 
     def test_noop(self):
         # test implicit by absence assertPdnsRequests
-        management.call_command('stop-abuse')
+        management.call_command("stop-abuse")
 
     def test_remove_rrsets_by_domain_name(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_domain.name)):
-            management.call_command('stop-abuse', self.my_domain)
-        self.assertEqual(models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1)  # only NS left
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_domain.name)
+        ):
+            management.call_command("stop-abuse", self.my_domain)
+        self.assertEqual(
+            models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1
+        )  # only NS left
         self.assertEqual(
-            set(models.RR.objects.filter(rrset__domain__name=self.my_domain.name).values_list('content', flat=True)),
+            set(
+                models.RR.objects.filter(
+                    rrset__domain__name=self.my_domain.name
+                ).values_list("content", flat=True)
+            ),
             set(settings.DEFAULT_NS),
         )
 
@@ -35,16 +52,24 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             expect_order=False,
         ):
-            management.call_command('stop-abuse', self.owner.email)
-        self.assertEqual(models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1)  # only NS left
+            management.call_command("stop-abuse", self.owner.email)
         self.assertEqual(
-            set(models.RR.objects.filter(rrset__domain__name=self.my_domain.name).values_list('content', flat=True)),
+            models.RRset.objects.filter(domain__name=self.my_domain.name).count(), 1
+        )  # only NS left
+        self.assertEqual(
+            set(
+                models.RR.objects.filter(
+                    rrset__domain__name=self.my_domain.name
+                ).values_list("content", flat=True)
+            ),
             set(settings.DEFAULT_NS),
         )
 
     def test_disable_user_by_domain_name(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_domain.name)):
-            management.call_command('stop-abuse', self.my_domain)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_domain.name)
+        ):
+            management.call_command("stop-abuse", self.my_domain)
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.is_active, False)
 
@@ -53,22 +78,28 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             expect_order=False,
         ):
-            management.call_command('stop-abuse', self.owner.email)
+            management.call_command("stop-abuse", self.owner.email)
         self.owner.refresh_from_db()
         self.assertEqual(self.owner.is_active, False)
 
     def test_keep_other_owned_domains_name(self):
-        with self.assertPdnsRequests(self.requests_desec_rr_sets_update(name=self.my_domain.name)):
-            management.call_command('stop-abuse', self.my_domain)
-        self.assertGreater(models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1)
+        with self.assertPdnsRequests(
+            self.requests_desec_rr_sets_update(name=self.my_domain.name)
+        ):
+            management.call_command("stop-abuse", self.my_domain)
+        self.assertGreater(
+            models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1
+        )
 
     def test_dont_keep_other_owned_domains_email(self):
         with self.assertPdnsRequests(
             *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains],
             expect_order=False,
         ):
-            management.call_command('stop-abuse', self.owner.email)
-        self.assertEqual(models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1)
+            management.call_command("stop-abuse", self.owner.email)
+        self.assertEqual(
+            models.RRset.objects.filter(domain__name=self.my_domains[1].name).count(), 1
+        )
 
     def test_only_disable_owner(self):
         with self.assertPdnsRequests(
@@ -76,7 +107,7 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             self.requests_desec_rr_sets_update(name=self.my_domains[1].name),
             expect_order=False,
         ):
-            management.call_command('stop-abuse', self.my_domain, self.owner.email)
+            management.call_command("stop-abuse", self.my_domain, self.owner.email)
         self.my_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.assertEqual(self.my_domain.owner.is_active, False)
@@ -88,7 +119,7 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
             self.requests_desec_rr_sets_update(name=self.other_domain.name),
             expect_order=False,
         ):
-            management.call_command('stop-abuse', self.my_domain, self.other_domain)
+            management.call_command("stop-abuse", self.my_domain, self.other_domain)
         self.my_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.assertEqual(self.my_domain.owner.is_active, False)
@@ -96,12 +127,18 @@ class StopAbuseCommandTest(DomainOwnerTestCase):
 
     def test_disable_owners_by_email(self):
         with self.assertPdnsRequests(
-            *[self.requests_desec_rr_sets_update(name=d.name) for d in self.my_domains + self.other_domains],
+            *[
+                self.requests_desec_rr_sets_update(name=d.name)
+                for d in self.my_domains + self.other_domains
+            ],
             expect_order=False,
         ):
-            management.call_command('stop-abuse', self.owner.email, *[d.owner.email for d in self.other_domains])
+            management.call_command(
+                "stop-abuse",
+                self.owner.email,
+                *[d.owner.email for d in self.other_domains],
+            )
         self.my_domain.owner.refresh_from_db()
         self.other_domain.owner.refresh_from_db()
         self.assertEqual(self.my_domain.owner.is_active, False)
         self.assertEqual(self.other_domain.owner.is_active, False)
-

+ 29 - 14
api/desecapi/tests/test_throttling.py

@@ -10,27 +10,33 @@ from rest_framework.test import APIRequestFactory
 
 
 def override_rates(rates):
-    return override_settings(REST_FRAMEWORK={'DEFAULT_THROTTLE_CLASSES': ['desecapi.throttling.ScopedRatesThrottle'],
-                                             'DEFAULT_THROTTLE_RATES': {'test_scope': rates}})
+    return override_settings(
+        REST_FRAMEWORK={
+            "DEFAULT_THROTTLE_CLASSES": ["desecapi.throttling.ScopedRatesThrottle"],
+            "DEFAULT_THROTTLE_RATES": {"test_scope": rates},
+        }
+    )
 
 
 class MockView(APIView):
-    throttle_scope = 'test_scope'
+    throttle_scope = "test_scope"
 
     @property
     def throttle_classes(self):
         # Need to import here so that the module is only loaded once the settings override is in effect
         from desecapi.throttling import ScopedRatesThrottle
+
         return (ScopedRatesThrottle,)
 
     def get(self, request):
-        return Response('foo')
+        return Response("foo")
 
 
 class ThrottlingTestCase(TestCase):
     """
     Based on DRF's test_throttling.py.
     """
+
     def setUp(self):
         super().setUp()
         self.factory = APIRequestFactory()
@@ -41,17 +47,24 @@ class ThrottlingTestCase(TestCase):
             sum_delay = 0
             for delay, count, max_wait in counts:
                 sum_delay += delay
-                with mock.patch('desecapi.throttling.ScopedRatesThrottle.timer', return_value=time.time() + sum_delay):
+                with mock.patch(
+                    "desecapi.throttling.ScopedRatesThrottle.timer",
+                    return_value=time.time() + sum_delay,
+                ):
                     for _ in range(count):
                         response = view(request)
                         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
                     response = view(request)
-                    self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
-                    self.assertTrue(max_wait - 1 <= float(response['Retry-After']) <= max_wait)
+                    self.assertEqual(
+                        response.status_code, status.HTTP_429_TOO_MANY_REQUESTS
+                    )
+                    self.assertTrue(
+                        max_wait - 1 <= float(response["Retry-After"]) <= max_wait
+                    )
 
         cache.clear()
-        request = self.factory.get('/')
+        request = self.factory.get("/")
         with override_rates(rates):
             do_test()
             if buckets is not None:
@@ -60,19 +73,21 @@ class ThrottlingTestCase(TestCase):
                     do_test()
 
     def test_requests_are_throttled_4sec(self):
-        self._test_requests_are_throttled(['4/sec'], [(0, 4, 1), (1, 4, 1)])
+        self._test_requests_are_throttled(["4/sec"], [(0, 4, 1), (1, 4, 1)])
 
     def test_requests_are_throttled_4min(self):
-        self._test_requests_are_throttled(['4/min'], [(0, 4, 60)])
+        self._test_requests_are_throttled(["4/min"], [(0, 4, 60)])
 
     def test_requests_are_throttled_multiple(self):
-        self._test_requests_are_throttled(['5/s', '4/day'], [(0, 4, 86400)])
-        self._test_requests_are_throttled(['4/s', '5/day'], [(0, 4, 1)])
+        self._test_requests_are_throttled(["5/s", "4/day"], [(0, 4, 86400)])
+        self._test_requests_are_throttled(["4/s", "5/day"], [(0, 4, 1)])
 
     def test_requests_are_throttled_multiple_cascade(self):
         # We test that we can do 4 requests in the first second and only 2 in the second second
-        self._test_requests_are_throttled(['4/s', '6/day'], [(0, 4, 1), (1, 2, 86400)])
+        self._test_requests_are_throttled(["4/s", "6/day"], [(0, 4, 1), (1, 2, 86400)])
 
     def test_requests_are_throttled_multiple_cascade_with_buckets(self):
         # We test that we can do 4 requests in the first second and only 2 in the second second
-        self._test_requests_are_throttled(['4/s', '6/day'], [(0, 4, 1), (1, 2, 86400)], buckets=['foo', 'bar'])
+        self._test_requests_are_throttled(
+            ["4/s", "6/day"], [(0, 4, 1), (1, 2, 86400)], buckets=["foo", "bar"]
+        )

+ 156 - 61
api/desecapi/tests/test_token_domain_policy.py

@@ -12,16 +12,20 @@ from desecapi.tests.base import DomainOwnerTestCase
 class TokenDomainPolicyClient(APIClient):
     def _request(self, method, url, *, using, **kwargs):
         if using is not None:
-            kwargs.update(HTTP_AUTHORIZATION=f'Token {using.plain}')
+            kwargs.update(HTTP_AUTHORIZATION=f"Token {using.plain}")
         return method(url, **kwargs)
 
     def _request_policy(self, method, target, *, using, domain, **kwargs):
-        domain = domain or 'default'
-        url = DomainOwnerTestCase.reverse('v1:token_domain_policies-detail', token_id=target.id, domain__name=domain)
+        domain = domain or "default"
+        url = DomainOwnerTestCase.reverse(
+            "v1:token_domain_policies-detail", token_id=target.id, domain__name=domain
+        )
         return self._request(method, url, using=using, **kwargs)
 
     def _request_policies(self, method, target, *, using, **kwargs):
-        url = DomainOwnerTestCase.reverse('v1:token_domain_policies-list', token_id=target.id)
+        url = DomainOwnerTestCase.reverse(
+            "v1:token_domain_policies-list", token_id=target.id
+        )
         return self._request(method, url, using=using, **kwargs)
 
     def list_policies(self, target, *, using):
@@ -34,7 +38,9 @@ class TokenDomainPolicyClient(APIClient):
         return self._request_policy(self.get, target, using=using, domain=domain)
 
     def patch_policy(self, target, *, using, domain, **kwargs):
-        return self._request_policy(self.patch, target, using=using, domain=domain, **kwargs)
+        return self._request_policy(
+            self.patch, target, using=using, domain=domain, **kwargs
+        )
 
     def delete_policy(self, target, *, using, domain):
         return self._request_policy(self.delete, target, using=using, domain=domain)
@@ -53,9 +59,13 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
     def test_policy_lifecycle_without_management_permission(self):
         # Prepare (with management token)
         data = dict(domain=None, perm_rrsets=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
-        response = self.client.create_policy(self.token_manage, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token_manage, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
 
         # Self-inspection is fine
@@ -75,7 +85,9 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
         ## Get
-        response = self.client.get_policy(self.token_manage, using=self.token, domain=None)
+        response = self.client.get_policy(
+            self.token_manage, using=self.token, domain=None
+        )
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
         # Write operations forbidden (self and other)
@@ -85,8 +97,12 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
             # Change
-            data = dict(domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True)
-            response = self.client.patch_policy(target, using=self.token, domain=self.my_domains[0].name, data=data)
+            data = dict(
+                domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True
+            )
+            response = self.client.patch_policy(
+                target, using=self.token, domain=self.my_domains[0].name, data=data
+            )
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
             # Delete
@@ -105,14 +121,19 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         ## without required field
         response = self.client.create_policy(self.token, using=self.token_manage)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data['domain'], ['This field is required.'])
+        self.assertEqual(response.data["domain"], ["This field is required."])
 
         ## without a default policy
         data = dict(domain=self.my_domains[0].name)
         with transaction.atomic():
-            response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+            response = self.client.create_policy(
+                self.token, using=self.token_manage, data=data
+            )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data['domain'], ['Policy precedence: The first policy must be the default policy.'])
+        self.assertEqual(
+            response.data["domain"],
+            ["Policy precedence: The first policy must be the default policy."],
+        )
 
         # List: still empty
         response = self.client.list_policies(self.token, using=self.token_manage)
@@ -122,38 +143,53 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         # Create
         ## default policy
         data = dict(domain=None, perm_rrsets=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
 
         ## can't create another default policy
         with transaction.atomic():
-            response = self.client.create_policy(self.token, using=self.token_manage, data=dict(domain=None))
+            response = self.client.create_policy(
+                self.token, using=self.token_manage, data=dict(domain=None)
+            )
         self.assertStatus(response, status.HTTP_409_CONFLICT)
 
         ## verify object creation
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=None)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=None
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
 
         ## can't create policy for other user's domain
         data = dict(domain=self.other_domain.name, perm_dyndns=True, perm_rrsets=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data['domain'][0].code, 'does_not_exist')
+        self.assertEqual(response.data["domain"][0].code, "does_not_exist")
 
         ## another policy
         data = dict(domain=self.my_domains[0].name, perm_dyndns=True)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
 
         ## can't create policy for the same domain
         with transaction.atomic():
-            response = self.client.create_policy(self.token, using=self.token_manage,
-                                                 data=dict(domain=self.my_domains[0].name, perm_dyndns=False))
+            response = self.client.create_policy(
+                self.token,
+                using=self.token_manage,
+                data=dict(domain=self.my_domains[0].name, perm_dyndns=False),
+            )
         self.assertStatus(response, status.HTTP_409_CONFLICT)
 
         ## verify object creation
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
 
@@ -165,56 +201,90 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
         # Change
         ## all fields of a policy
         data = dict(domain=self.my_domains[1].name, perm_dyndns=False, perm_rrsets=True)
-        response = self.client.patch_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name,
-                                            data=data)
+        response = self.client.patch_policy(
+            self.token,
+            using=self.token_manage,
+            domain=self.my_domains[0].name,
+            data=data,
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
 
         ## verify modification
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[1].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[1].name
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
 
         ## verify that policy for former domain is gone
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
         ## verify that the default policy can't be changed to a non-default policy
         with transaction.atomic():
-            response = self.client.patch_policy(self.token, using=self.token_manage, domain=None, data=data)
+            response = self.client.patch_policy(
+                self.token, using=self.token_manage, domain=None, data=data
+            )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data,
-                         {'domain': ['Policy precedence: Cannot disable default policy when others exist.']})
+        self.assertEqual(
+            response.data,
+            {
+                "domain": [
+                    "Policy precedence: Cannot disable default policy when others exist."
+                ]
+            },
+        )
 
         ## partially modify the default policy
         data = dict(perm_dyndns=True)
-        response = self.client.patch_policy(self.token, using=self.token_manage, domain=None, data=data)
+        response = self.client.patch_policy(
+            self.token, using=self.token_manage, domain=None, data=data
+        )
         self.assertStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data, {'domain': None, 'perm_rrsets': True} | data)
+        self.assertEqual(response.data, {"domain": None, "perm_rrsets": True} | data)
 
         # Delete
         ## can't delete default policy while others exist
         with transaction.atomic():
-            response = self.client.delete_policy(self.token, using=self.token_manage, domain=None)
+            response = self.client.delete_policy(
+                self.token, using=self.token_manage, domain=None
+            )
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertEqual(response.data,
-                         {'domain': ["Policy precedence: Can't delete default policy when there exist others."]})
+        self.assertEqual(
+            response.data,
+            {
+                "domain": [
+                    "Policy precedence: Can't delete default policy when there exist others."
+                ]
+            },
+        )
 
         ## delete other policy
-        response = self.client.delete_policy(self.token, using=self.token_manage, domain=self.my_domains[1].name)
+        response = self.client.delete_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[1].name
+        )
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
         ## delete default policy
-        response = self.client.delete_policy(self.token, using=self.token_manage, domain=None)
+        response = self.client.delete_policy(
+            self.token, using=self.token_manage, domain=None
+        )
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
         ## idempotence: delete a non-existing policy
-        response = self.client.delete_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.delete_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
 
         ## verify that policies are gone
         for domain in [None, self.my_domains[0].name, self.my_domains[1].name]:
-            response = self.client.get_policy(self.token, using=self.token_manage, domain=domain)
+            response = self.client.get_policy(
+                self.token, using=self.token_manage, domain=domain
+            )
             self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
         # List: empty again
@@ -233,27 +303,31 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
             responses = []
             if value:
                 pdns_name = self._normalize_name(name).lower()
-                cm = self.assertPdnsNoRequestsBut(self.request_pdns_zone_update(name=pdns_name),
-                                                  self.request_pdns_zone_axfr(name=pdns_name))
+                cm = self.assertPdnsNoRequestsBut(
+                    self.request_pdns_zone_update(name=pdns_name),
+                    self.request_pdns_zone_axfr(name=pdns_name),
+                )
             else:
                 cm = nullcontext()
 
-            if perm == 'perm_dyndns':
-                data = {'username': name, 'password': self.token.plain}
+            if perm == "perm_dyndns":
+                data = {"username": name, "password": self.token.plain}
                 with cm:
-                    responses.append(self.client.get(self.reverse('v1:dyndns12update'), data))
+                    responses.append(
+                        self.client.get(self.reverse("v1:dyndns12update"), data)
+                    )
                 return responses
 
-            if perm == 'perm_rrsets':
-                url_detail = self.reverse('v1:rrset@', name=name, subname='', type='A')
-                url_list = self.reverse('v1:rrsets', name=name)
+            if perm == "perm_rrsets":
+                url_detail = self.reverse("v1:rrset@", name=name, subname="", type="A")
+                url_list = self.reverse("v1:rrsets", name=name)
 
                 responses.append(self.client.get(url_list, **kwargs))
                 responses.append(self.client.patch(url_list, [], **kwargs))
                 responses.append(self.client.put(url_list, [], **kwargs))
                 responses.append(self.client.post(url_list, [], **kwargs))
 
-                data = {'subname': '', 'type': 'A', 'ttl': 3600, 'records': ['1.2.3.4']}
+                data = {"subname": "", "type": "A", "ttl": 3600, "records": ["1.2.3.4"]}
                 with cm:
                     responses += [
                         self.client.delete(url_detail, **kwargs),
@@ -264,30 +338,40 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
                     ]
                 return responses
 
-            raise ValueError(f'Unexpected permission: {perm}')
+            raise ValueError(f"Unexpected permission: {perm}")
 
         # Create
         ## default policy
         data = dict(domain=None)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
 
         ## another policy
         data = dict(domain=self.my_domains[0].name)
-        response = self.client.create_policy(self.token, using=self.token_manage, data=data)
+        response = self.client.create_policy(
+            self.token, using=self.token_manage, data=data
+        )
         self.assertStatus(response, status.HTTP_201_CREATED)
 
         ## verify object creation
-        response = self.client.get_policy(self.token, using=self.token_manage, domain=self.my_domains[0].name)
+        response = self.client.get_policy(
+            self.token, using=self.token_manage, domain=self.my_domains[0].name
+        )
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data, self.default_data | data)
 
         policies = {
-            self.my_domains[0]: self.token.tokendomainpolicy_set.get(domain__isnull=False),
-            self.my_domains[1]: self.token.tokendomainpolicy_set.get(domain__isnull=True),
+            self.my_domains[0]: self.token.tokendomainpolicy_set.get(
+                domain__isnull=False
+            ),
+            self.my_domains[1]: self.token.tokendomainpolicy_set.get(
+                domain__isnull=True
+            ),
         }
 
-        kwargs = dict(HTTP_AUTHORIZATION=f'Token {self.token.plain}')
+        kwargs = dict(HTTP_AUTHORIZATION=f"Token {self.token.plain}")
 
         # For each permission type
         for perm in self.default_data.keys():
@@ -302,19 +386,23 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
                     policy.save()
 
                     # Perform requests that test this permission and inspect responses
-                    for response in _perform_requests(domain.name, perm, value, **kwargs):
+                    for response in _perform_requests(
+                        domain.name, perm, value, **kwargs
+                    ):
                         if value:
                             self.assertIn(response.status_code, range(200, 300))
                         else:
                             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
                     # Can't create domain
-                    data = {'name': self.random_domain_name()}
-                    response = self.client.post(self.reverse('v1:domain-list'), data, **kwargs)
+                    data = {"name": self.random_domain_name()}
+                    response = self.client.post(
+                        self.reverse("v1:domain-list"), data, **kwargs
+                    )
                     self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
                     # Can't access account details
-                    response = self.client.get(self.reverse('v1:account'), **kwargs)
+                    response = self.client.get(self.reverse("v1:account"), **kwargs)
                     self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
     def test_domain_owner_consistency(self):
@@ -349,7 +437,9 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
 
         with self.assertRaises(IntegrityError):
             with transaction.atomic():  # https://stackoverflow.com/a/23326971/6867099
-                models.TokenDomainPolicy(token=self.token, domain=self.other_domains[0]).save()
+                models.TokenDomainPolicy(
+                    token=self.token, domain=self.other_domains[0]
+                ).save()
 
         self.token.user = self.other_domain.owner
         with self.assertRaises(IntegrityError):
@@ -363,7 +453,10 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
 
         domain = domains.pop()
         domain.delete()
-        self.assertEqual(list(map(lambda x: x.domain, self.token.tokendomainpolicy_set.all())), domains)
+        self.assertEqual(
+            list(map(lambda x: x.domain, self.token.tokendomainpolicy_set.all())),
+            domains,
+        )
 
     def test_token_deletion(self):
         domains = [None] + self.my_domains[:2]
@@ -375,7 +468,9 @@ class TokenDomainPolicyTestCase(DomainOwnerTestCase):
 
         self.token.delete()
         for domain, policy in policies.items():
-            self.assertFalse(models.TokenDomainPolicy.objects.filter(pk=policy.pk).exists())
+            self.assertFalse(
+                models.TokenDomainPolicy.objects.filter(pk=policy.pk).exists()
+            )
             if domain:
                 self.assertTrue(models.Domain.objects.filter(pk=domain.pk).exists())
 

+ 8 - 5
api/desecapi/tests/test_token_policies.py

@@ -4,7 +4,6 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 class TokenPoliciesTestCase(DomainOwnerTestCase):
-
     def setUp(self):
         super().setUp()
         self.client.credentials()  # remove default credential (corresponding to domain owner)
@@ -12,20 +11,24 @@ class TokenPoliciesTestCase(DomainOwnerTestCase):
         self.other_token = self.create_token(self.user)
 
     def test_policies(self):
-        url = DomainOwnerTestCase.reverse('v1:token-policies-root', token_id=self.token.id)
+        url = DomainOwnerTestCase.reverse(
+            "v1:token-policies-root", token_id=self.token.id
+        )
 
         kwargs = {}
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_401_UNAUTHORIZED)
 
-        kwargs.update(HTTP_AUTHORIZATION=f'Token {self.token_manage.plain}')
+        kwargs.update(HTTP_AUTHORIZATION=f"Token {self.token_manage.plain}")
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_200_OK)
 
-        kwargs.update(HTTP_AUTHORIZATION=f'Token {self.token.plain}')
+        kwargs.update(HTTP_AUTHORIZATION=f"Token {self.token.plain}")
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_200_OK)
 
-        url = DomainOwnerTestCase.reverse('v1:token-policies-root', token_id=self.token_manage.id)
+        url = DomainOwnerTestCase.reverse(
+            "v1:token-policies-root", token_id=self.token_manage.id
+        )
         response = self.client.get(url, **kwargs)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)

+ 92 - 49
api/desecapi/tests/test_tokens.py

@@ -5,31 +5,37 @@ from desecapi.tests.base import DomainOwnerTestCase
 
 
 class TokenPermittedTestCase(DomainOwnerTestCase):
-
     def setUp(self):
         super().setUp()
         self.token.perm_manage_tokens = True
         self.token.save()
-        self.token2 = self.create_token(self.owner, name='testtoken')
+        self.token2 = self.create_token(self.owner, name="testtoken")
         self.other_token = self.create_token(self.user)
 
     def test_token_last_used(self):
         self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
-        self.client.get(self.reverse('v1:root'))
+        self.client.get(self.reverse("v1:root"))
         self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
 
     def test_list_tokens(self):
-        response = self.client.get(self.reverse('v1:token-list'))
+        response = self.client.get(self.reverse("v1:token-list"))
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 2)
-        self.assertIn('id', response.data[0])
-        self.assertFalse(any(field in response.data[0] for field in ['token', 'key', 'value']))
-        self.assertFalse(any(token.encode() in response.content for token in [self.token.plain, self.token2.plain]))
+        self.assertIn("id", response.data[0])
+        self.assertFalse(
+            any(field in response.data[0] for field in ["token", "key", "value"])
+        )
+        self.assertFalse(
+            any(
+                token.encode() in response.content
+                for token in [self.token.plain, self.token2.plain]
+            )
+        )
         self.assertNotContains(response, self.token.plain)
 
     def test_delete_my_token(self):
-        token_id = Token.objects.get(user=self.owner, name='testtoken').id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        token_id = Token.objects.get(user=self.owner, name="testtoken").id
+        url = self.reverse("v1:token-detail", pk=token_id)
 
         response = self.client.delete(url)
         self.assertStatus(response, status.HTTP_204_NO_CONTENT)
@@ -39,37 +45,51 @@ class TokenPermittedTestCase(DomainOwnerTestCase):
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
     def test_retrieve_my_token(self):
-        token_id = Token.objects.get(user=self.owner, name='testtoken').id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        token_id = Token.objects.get(user=self.owner, name="testtoken").id
+        url = self.reverse("v1:token-detail", pk=token_id)
 
         response = self.client.get(url)
         self.assertStatus(response, status.HTTP_200_OK)
         self.assertEqual(
             set(response.data.keys()),
-            {'id', 'created', 'last_used', 'max_age', 'max_unused_period', 'name', 'perm_manage_tokens',
-             'allowed_subnets', 'is_valid'}
+            {
+                "id",
+                "created",
+                "last_used",
+                "max_age",
+                "max_unused_period",
+                "name",
+                "perm_manage_tokens",
+                "allowed_subnets",
+                "is_valid",
+            },
+        )
+        self.assertFalse(
+            any(
+                token.encode() in response.content
+                for token in [self.token.plain, self.token2.plain]
+            )
         )
-        self.assertFalse(any(token.encode() in response.content for token in [self.token.plain, self.token2.plain]))
 
     def test_retrieve_other_token(self):
         token_id = Token.objects.get(user=self.user).id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        url = self.reverse("v1:token-detail", pk=token_id)
 
         response = self.client.get(url)
         self.assertStatus(response, status.HTTP_404_NOT_FOUND)
 
     def test_update_my_token(self):
-        url = self.reverse('v1:token-detail', pk=self.token.id)
+        url = self.reverse("v1:token-detail", pk=self.token.id)
 
         for method in [self.client.patch, self.client.put]:
             datas = [
-                {'name': method.__name__},
-                {'allowed_subnets': ['127.0.0.0/8']},
-                {'allowed_subnets': ['127.0.0.0/8', '::/0']},
-                {'max_age': '365 00:10:33.123456'},
-                {'max_age': None},
-                {'max_unused_period': '365 00:10:33.123456'},
-                {'max_unused_period': None},
+                {"name": method.__name__},
+                {"allowed_subnets": ["127.0.0.0/8"]},
+                {"allowed_subnets": ["127.0.0.0/8", "::/0"]},
+                {"max_age": "365 00:10:33.123456"},
+                {"max_age": None},
+                {"max_unused_period": "365 00:10:33.123456"},
+                {"max_unused_period": None},
             ]
             for data in datas:
                 response = method(url, data=data)
@@ -78,11 +98,11 @@ class TokenPermittedTestCase(DomainOwnerTestCase):
                     self.assertEqual(response.data[k], v)
 
         # Revoke token management permission
-        response = self.client.patch(url, data={'perm_manage_tokens': False})
+        response = self.client.patch(url, data={"perm_manage_tokens": False})
         self.assertStatus(response, status.HTTP_200_OK)
 
         # Verify that the change cannot be undone
-        response = self.client.patch(url, data={'perm_manage_tokens': True})
+        response = self.client.patch(url, data={"perm_manage_tokens": True})
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
     def test_create_token(self):
@@ -90,70 +110,93 @@ class TokenPermittedTestCase(DomainOwnerTestCase):
 
         datas = [
             {},
-            {'name': '', 'perm_manage_tokens': True},
-            {'name': 'foobar'},
-            {'allowed_subnets': ['1.2.3.32/28', 'bade::affe/128']},
+            {"name": "", "perm_manage_tokens": True},
+            {"name": "foobar"},
+            {"allowed_subnets": ["1.2.3.32/28", "bade::affe/128"]},
         ]
         for data in datas:
-            response = self.client.post(self.reverse('v1:token-list'), data=data)
+            response = self.client.post(self.reverse("v1:token-list"), data=data)
             self.assertStatus(response, status.HTTP_201_CREATED)
             self.assertEqual(
                 set(response.data.keys()),
-                {'id', 'created', 'last_used', 'max_age', 'max_unused_period', 'name', 'perm_manage_tokens',
-                 'allowed_subnets', 'is_valid', 'token'}
+                {
+                    "id",
+                    "created",
+                    "last_used",
+                    "max_age",
+                    "max_unused_period",
+                    "name",
+                    "perm_manage_tokens",
+                    "allowed_subnets",
+                    "is_valid",
+                    "token",
+                },
             )
-            self.assertEqual(response.data['name'], data.get('name', ''))
-            self.assertEqual(response.data['allowed_subnets'], data.get('allowed_subnets', ['0.0.0.0/0', '::/0']))
-            self.assertEqual(response.data['perm_manage_tokens'], data.get('perm_manage_tokens', False))
-            self.assertIsNone(response.data['last_used'])
+            self.assertEqual(response.data["name"], data.get("name", ""))
+            self.assertEqual(
+                response.data["allowed_subnets"],
+                data.get("allowed_subnets", ["0.0.0.0/0", "::/0"]),
+            )
+            self.assertEqual(
+                response.data["perm_manage_tokens"],
+                data.get("perm_manage_tokens", False),
+            )
+            self.assertIsNone(response.data["last_used"])
 
-        self.assertEqual(len(Token.objects.filter(user=self.owner).all()), n + len(datas))
+        self.assertEqual(
+            len(Token.objects.filter(user=self.owner).all()), n + len(datas)
+        )
 
 
 class TokenForbiddenTestCase(DomainOwnerTestCase):
-
     def setUp(self):
         super().setUp()
-        self.token2 = self.create_token(self.owner, name='testtoken')
+        self.token2 = self.create_token(self.owner, name="testtoken")
         self.other_token = self.create_token(self.user)
 
     def test_token_last_used(self):
         self.assertIsNone(Token.objects.get(pk=self.token.id).last_used)
-        self.client.get(self.reverse('v1:root'))
+        self.client.get(self.reverse("v1:root"))
         self.assertIsNotNone(Token.objects.get(pk=self.token.id).last_used)
 
     def test_list_tokens(self):
-        response = self.client.get(self.reverse('v1:token-list'))
+        response = self.client.get(self.reverse("v1:token-list"))
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
     def test_delete_my_token(self):
-        for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
-            url = self.reverse('v1:token-detail', pk=token_id)
+        for token_id in [
+            Token.objects.get(user=self.owner, name="testtoken").id,
+            self.token.id,
+        ]:
+            url = self.reverse("v1:token-detail", pk=token_id)
             response = self.client.delete(url)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
     def test_retrieve_my_token(self):
-        for token_id in [Token.objects.get(user=self.owner, name='testtoken').id, self.token.id]:
-            url = self.reverse('v1:token-detail', pk=token_id)
+        for token_id in [
+            Token.objects.get(user=self.owner, name="testtoken").id,
+            self.token.id,
+        ]:
+            url = self.reverse("v1:token-detail", pk=token_id)
             response = self.client.get(url)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
     def test_retrieve_other_token(self):
         token_id = Token.objects.get(user=self.user).id
-        url = self.reverse('v1:token-detail', pk=token_id)
+        url = self.reverse("v1:token-detail", pk=token_id)
         response = self.client.get(url)
         self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
     def test_update_my_token(self):
-        url = self.reverse('v1:token-detail', pk=self.token.id)
+        url = self.reverse("v1:token-detail", pk=self.token.id)
         for method in [self.client.patch, self.client.put]:
-            datas = [{'name': method.__name__}, {'allowed_subnets': ['127.0.0.0/8']}]
+            datas = [{"name": method.__name__}, {"allowed_subnets": ["127.0.0.0/8"]}]
             for data in datas:
                 response = method(url, data=data)
                 self.assertStatus(response, status.HTTP_403_FORBIDDEN)
 
     def test_create_token(self):
-        datas = [{}, {'name': ''}, {'name': 'foobar'}]
+        datas = [{}, {"name": ""}, {"name": "foobar"}]
         for data in datas:
-            response = self.client.post(self.reverse('v1:token-list'), data=data)
+            response = self.client.post(self.reverse("v1:token-list"), data=data)
             self.assertStatus(response, status.HTTP_403_FORBIDDEN)

文件差異過大導致無法顯示
+ 303 - 179
api/desecapi/tests/test_user_management.py


+ 16 - 6
api/desecapi/throttling.py

@@ -10,6 +10,7 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
     """
     Like DRF's ScopedRateThrottle, but supports several rates per scope, e.g. for burst vs. sustained limit.
     """
+
     def parse_rate(self, rates):
         return [super(ScopedRatesThrottle, self).parse_rate(rate) for rate in rates]
 
@@ -27,9 +28,9 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
             return True
 
         # Amend scope with optional bucket
-        bucket = getattr(view, self.scope_attr + '_bucket', None)
+        bucket = getattr(view, self.scope_attr + "_bucket", None)
         if bucket is not None:
-            self.scope += ':' + sha1(bucket.encode()).hexdigest()
+            self.scope += ":" + sha1(bucket.encode()).hexdigest()
 
         self.now = self.timer()
         self.num_requests, self.duration = zip(*self.parse_rate(self.rate))
@@ -37,7 +38,9 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
         self.history = {key: [] for key in self.key}
         self.history.update(self.cache.get_many(self.key))
 
-        for num_requests, duration, key in zip(self.num_requests, self.duration, self.key):
+        for num_requests, duration, key in zip(
+            self.num_requests, self.duration, self.key
+        ):
             history = self.history[key]
             # Drop any requests from the history which have now passed the
             # throttle duration
@@ -45,9 +48,16 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
                 history.pop()
             if len(history) >= num_requests:
                 # Prepare variables used by the Throttle's wait() method that gets called by APIView.check_throttles()
-                self.num_requests, self.duration, self.key, self.history = num_requests, duration, key, history
+                self.num_requests, self.duration, self.key, self.history = (
+                    num_requests,
+                    duration,
+                    key,
+                    history,
+                )
                 response = self.throttle_failure()
-                metrics.get('desecapi_throttle_failure').labels(request.method, scope, request.user.pk, bucket).inc()
+                metrics.get("desecapi_throttle_failure").labels(
+                    request.method, scope, request.user.pk, bucket
+                ).inc()
                 return response
             self.history[key] = history
         return self.throttle_success()
@@ -65,4 +75,4 @@ class ScopedRatesThrottle(throttling.ScopedRateThrottle):
 
     def get_cache_key(self, request, view):
         key = super().get_cache_key(request, view)
-        return [f'{key}_{duration}' for duration in self.duration]
+        return [f"{key}_{duration}" for duration in self.duration]

+ 96 - 44
api/desecapi/urls/version_1.py

@@ -4,69 +4,121 @@ from rest_framework.routers import SimpleRouter
 from desecapi import views
 
 tokens_router = SimpleRouter()
-tokens_router.register(r'', views.TokenViewSet, basename='token')
+tokens_router.register(r"", views.TokenViewSet, basename="token")
 
 tokendomainpolicies_router = SimpleRouter()
-tokendomainpolicies_router.register(r'', views.TokenDomainPolicyViewSet, basename='token_domain_policies')
+tokendomainpolicies_router.register(
+    r"", views.TokenDomainPolicyViewSet, basename="token_domain_policies"
+)
 
 auth_urls = [
     # User management
-    path('', views.AccountCreateView.as_view(), name='register'),
-    path('account/', views.AccountView.as_view(), name='account'),
-    path('account/delete/', views.AccountDeleteView.as_view(), name='account-delete'),
-    path('account/change-email/', views.AccountChangeEmailView.as_view(), name='account-change-email'),
-    path('account/reset-password/', views.AccountResetPasswordView.as_view(), name='account-reset-password'),
-    path('login/', views.AccountLoginView.as_view(), name='login'),
-    path('logout/', views.AccountLogoutView.as_view(), name='logout'),
-
+    path("", views.AccountCreateView.as_view(), name="register"),
+    path("account/", views.AccountView.as_view(), name="account"),
+    path("account/delete/", views.AccountDeleteView.as_view(), name="account-delete"),
+    path(
+        "account/change-email/",
+        views.AccountChangeEmailView.as_view(),
+        name="account-change-email",
+    ),
+    path(
+        "account/reset-password/",
+        views.AccountResetPasswordView.as_view(),
+        name="account-reset-password",
+    ),
+    path("login/", views.AccountLoginView.as_view(), name="login"),
+    path("logout/", views.AccountLogoutView.as_view(), name="logout"),
     # Token management
-    path('tokens/', include(tokens_router.urls)),
-    path('tokens/<uuid:token_id>/policies/', views.TokenPoliciesRoot.as_view(), name='token-policies-root'),
-    path('tokens/<uuid:token_id>/policies/domain/', include(tokendomainpolicies_router.urls)),
+    path("tokens/", include(tokens_router.urls)),
+    path(
+        "tokens/<uuid:token_id>/policies/",
+        views.TokenPoliciesRoot.as_view(),
+        name="token-policies-root",
+    ),
+    path(
+        "tokens/<uuid:token_id>/policies/domain/",
+        include(tokendomainpolicies_router.urls),
+    ),
 ]
 
 domains_router = SimpleRouter()
-domains_router.register(r'', views.DomainViewSet, basename='domain')
+domains_router.register(r"", views.DomainViewSet, basename="domain")
 
 api_urls = [
     # API home
-    path('', views.Root.as_view(), name='root'),
-
+    path("", views.Root.as_view(), name="root"),
     # Domain and RRSet management
-    path('domains/', include(domains_router.urls)),
-    path('domains/<name>/rrsets/', views.RRsetList.as_view(), name='rrsets'),
-    path('domains/<name>/rrsets/.../<type>/', views.RRsetDetail.as_view(), kwargs={'subname': ''}),
-    re_path(r'^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)\.\.\./(?P<type>[^/]+)/$',
-            views.RRsetDetail.as_view(), name='rrset'),
-    path('domains/<name>/rrsets/@/<type>/', views.RRsetDetail.as_view(), kwargs={'subname': ''}),
-    re_path(r'^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)@/(?P<type>[^/]+)/$',
-            views.RRsetDetail.as_view(), name='rrset@'),
-    path('domains/<name>/rrsets/<subname>/<type>/', views.RRsetDetail.as_view()),
-
+    path("domains/", include(domains_router.urls)),
+    path("domains/<name>/rrsets/", views.RRsetList.as_view(), name="rrsets"),
+    path(
+        "domains/<name>/rrsets/.../<type>/",
+        views.RRsetDetail.as_view(),
+        kwargs={"subname": ""},
+    ),
+    re_path(
+        r"^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)\.\.\./(?P<type>[^/]+)/$",
+        views.RRsetDetail.as_view(),
+        name="rrset",
+    ),
+    path(
+        "domains/<name>/rrsets/@/<type>/",
+        views.RRsetDetail.as_view(),
+        kwargs={"subname": ""},
+    ),
+    re_path(
+        r"^domains/(?P<name>[^/]+)/rrsets/(?P<subname>[^/]*)@/(?P<type>[^/]+)/$",
+        views.RRsetDetail.as_view(),
+        name="rrset@",
+    ),
+    path("domains/<name>/rrsets/<subname>/<type>/", views.RRsetDetail.as_view()),
     # DynDNS update
-    path('dyndns/update', views.DynDNS12UpdateView.as_view(), name='dyndns12update'),
-
+    path("dyndns/update", views.DynDNS12UpdateView.as_view(), name="dyndns12update"),
     # Serials
-    path('serials/', views.SerialListView.as_view(), name='serial'),
-
+    path("serials/", views.SerialListView.as_view(), name="serial"),
     # Donation
-    path('donation/', views.DonationList.as_view(), name='donation'),
-
+    path("donation/", views.DonationList.as_view(), name="donation"),
     # Authenticated Actions
-    path('v/activate-account/<code>/', views.AuthenticatedActivateUserActionView.as_view(), name='confirm-activate-account'),
-    path('v/change-email/<code>/', views.AuthenticatedChangeEmailUserActionView.as_view(), name='confirm-change-email'),
-    path('v/change-outreach-preference/<code>/', views.AuthenticatedChangeOutreachPreferenceUserActionView.as_view(), name='confirm-change-outreach-preference'),
-    path('v/confirm-account/<code>/', views.AuthenticatedConfirmAccountUserActionView.as_view(), name='confirm-confirm-account'),
-    path('v/reset-password/<code>/', views.AuthenticatedResetPasswordUserActionView.as_view(), name='confirm-reset-password'),
-    path('v/delete-account/<code>/', views.AuthenticatedDeleteUserActionView.as_view(), name='confirm-delete-account'),
-    path('v/renew-domain/<code>/', views.AuthenticatedRenewDomainBasicUserActionView.as_view(), name='confirm-renew-domain'),
-
+    path(
+        "v/activate-account/<code>/",
+        views.AuthenticatedActivateUserActionView.as_view(),
+        name="confirm-activate-account",
+    ),
+    path(
+        "v/change-email/<code>/",
+        views.AuthenticatedChangeEmailUserActionView.as_view(),
+        name="confirm-change-email",
+    ),
+    path(
+        "v/change-outreach-preference/<code>/",
+        views.AuthenticatedChangeOutreachPreferenceUserActionView.as_view(),
+        name="confirm-change-outreach-preference",
+    ),
+    path(
+        "v/confirm-account/<code>/",
+        views.AuthenticatedConfirmAccountUserActionView.as_view(),
+        name="confirm-confirm-account",
+    ),
+    path(
+        "v/reset-password/<code>/",
+        views.AuthenticatedResetPasswordUserActionView.as_view(),
+        name="confirm-reset-password",
+    ),
+    path(
+        "v/delete-account/<code>/",
+        views.AuthenticatedDeleteUserActionView.as_view(),
+        name="confirm-delete-account",
+    ),
+    path(
+        "v/renew-domain/<code>/",
+        views.AuthenticatedRenewDomainBasicUserActionView.as_view(),
+        name="confirm-renew-domain",
+    ),
     # CAPTCHA
-    path('captcha/', views.CaptchaView.as_view(), name='captcha'),
+    path("captcha/", views.CaptchaView.as_view(), name="captcha"),
 ]
 
-app_name = 'desecapi'
+app_name = "desecapi"
 urlpatterns = [
-    path('auth/', include(auth_urls)),
-    path('', include(api_urls)),
+    path("auth/", include(auth_urls)),
+    path("", include(api_urls)),
 ]

+ 1 - 1
api/desecapi/urls/version_2.py

@@ -1,4 +1,4 @@
 from desecapi.urls.version_1 import urlpatterns as version_1
 
-app_name = 'desecapi'
+app_name = "desecapi"
 urlpatterns = version_1

+ 12 - 10
api/desecapi/validators.py

@@ -18,7 +18,8 @@ class ExclusionConstraintValidator(UniqueTogetherValidator):
     Should be applied to the serializer class, not to an individual field.
     No-op if parent serializer is a list serializer (many=True). We expect the list serializer to assure exclusivity.
     """
-    message = 'This field violates an exclusion constraint.'
+
+    message = "This field violates an exclusion constraint."
 
     def __init__(self, queryset, fields, exclusion_condition, message=None):
         super().__init__(queryset, fields, message)
@@ -39,7 +40,7 @@ class ExclusionConstraintValidator(UniqueTogetherValidator):
 
     def __call__(self, attrs, serializer, *args, **kwargs):
         # Ignore validation if the many flag is set
-        if getattr(serializer.root, 'many', False):
+        if getattr(serializer.root, "many", False):
             return
 
         self.enforce_required_fields(attrs, serializer)
@@ -52,15 +53,15 @@ class ExclusionConstraintValidator(UniqueTogetherValidator):
             value for field, value in attrs.items() if field in self.fields
         ]
         if None not in checked_values and qs_exists(queryset):
-            types = queryset.values_list('type', flat=True)
-            types = ', '.join(types)
+            types = queryset.values_list("type", flat=True)
+            types = ", ".join(types)
             message = self.message.format(types=types)
-            raise ValidationError(message, code='exclusive')
+            raise ValidationError(message, code="exclusive")
 
 
 class Validator:
 
-    message = 'This field did not pass validation.'
+    message = "This field did not pass validation."
 
     def __init__(self, message=None):
         self.field_name = None
@@ -71,15 +72,16 @@ class Validator:
         raise NotImplementedError
 
     def __repr__(self):
-        return '<%s>' % self.__class__.__name__
+        return "<%s>" % self.__class__.__name__
+
 
 class ReadOnlyOnUpdateValidator(Validator):
 
-    message = 'Can only be written on create.'
+    message = "Can only be written on create."
     requires_context = True
 
     def __call__(self, value, serializer_field):
         field_name = serializer_field.source_attrs[-1]
-        instance = getattr(serializer_field.parent, 'instance', None)
+        instance = getattr(serializer_field.parent, "instance", None)
         if isinstance(instance, Model) and value != getattr(instance, field_name):
-            raise serializers.ValidationError(self.message, code='read-only-on-update')
+            raise serializers.ValidationError(self.message, code="read-only-on-update")

+ 92 - 45
api/desecapi/views/authenticated_actions.py

@@ -26,9 +26,10 @@ class AuthenticatedActionView(generics.GenericAPIView):
     Accept: text/html	forward to `self.html_url` if any   perform action      405 Method Not Allowed
     else                HTTP 406 Not Acceptable             perform action      405 Method Not Allowed
     """
+
     authenticated_action = None
     html_url = None  # Redirect GET requests to this webapp GUI URL
-    http_method_names = ['get', 'post']  # GET is for redirect only
+    http_method_names = ["get", "post"]  # GET is for redirect only
     renderer_classes = [JSONRenderer, StaticHTMLRenderer]
     _authenticated_action = None
 
@@ -38,10 +39,14 @@ class AuthenticatedActionView(generics.GenericAPIView):
             serializer = self.get_serializer(data=self.request.data)
             serializer.is_valid(raise_exception=True)
             try:
-                self._authenticated_action = serializer.Meta.model(**serializer.validated_data)
+                self._authenticated_action = serializer.Meta.model(
+                    **serializer.validated_data
+                )
             except ValueError:  # this happens when state cannot be verified
-                ex = ValidationError('This action cannot be carried out because another operation has been performed, '
-                                     'invalidating this one. (Are you trying to perform this action twice?)')
+                ex = ValidationError(
+                    "This action cannot be carried out because another operation has been performed, "
+                    "invalidating this one. (Are you trying to perform this action twice?)"
+                )
                 ex.status_code = status.HTTP_409_CONFLICT
                 raise ex
         return self._authenticated_action
@@ -49,26 +54,38 @@ class AuthenticatedActionView(generics.GenericAPIView):
     @property
     def authentication_classes(self):
         # This prevents both auth action code evaluation and user-specific throttling when we only want a redirect
-        return () if self.request.method in SAFE_METHODS else (auth.AuthenticatedBasicUserActionAuthentication,)
+        return (
+            ()
+            if self.request.method in SAFE_METHODS
+            else (auth.AuthenticatedBasicUserActionAuthentication,)
+        )
 
     @property
     def permission_classes(self):
-        return () if self.request.method in SAFE_METHODS else (permissions.IsActiveUser,)
+        return (
+            () if self.request.method in SAFE_METHODS else (permissions.IsActiveUser,)
+        )
 
     @property
     def throttle_scope(self):
-        return 'account_management_passive' if self.request.method in SAFE_METHODS else 'account_management_active'
+        return (
+            "account_management_passive"
+            if self.request.method in SAFE_METHODS
+            else "account_management_active"
+        )
 
     def get_serializer_context(self):
         return {
             **super().get_serializer_context(),
-            'code': self.kwargs['code'],
-            'validity_period': self.get_serializer_class().validity_period,
+            "code": self.kwargs["code"],
+            "validity_period": self.get_serializer_class().validity_period,
         }
 
     def get(self, request, *args, **kwargs):
         # Redirect browsers to frontend if available
-        is_redirect = (request.accepted_renderer.format == 'html') and self.html_url is not None
+        is_redirect = (
+            request.accepted_renderer.format == "html"
+        ) and self.html_url is not None
         if is_redirect:
             # Careful: This can generally lead to an open redirect if values contain slashes!
             # However, it cannot happen for Django view kwargs.
@@ -82,16 +99,22 @@ class AuthenticatedActionView(generics.GenericAPIView):
 
 
 class AuthenticatedChangeOutreachPreferenceUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/change-outreach-preference/{code}/'
-    serializer_class = serializers.AuthenticatedChangeOutreachPreferenceUserActionSerializer
+    html_url = "/confirm/change-outreach-preference/{code}/"
+    serializer_class = (
+        serializers.AuthenticatedChangeOutreachPreferenceUserActionSerializer
+    )
 
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'Thank you! We have recorded that you would not like to receive outreach messages.'})
+        return Response(
+            {
+                "detail": "Thank you! We have recorded that you would not like to receive outreach messages."
+            }
+        )
 
 
 class AuthenticatedActivateUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/activate-account/{code}/'
+    html_url = "/confirm/activate-account/{code}/"
     permission_classes = ()  # don't require that user is activated already
     serializer_class = serializers.AuthenticatedActivateUserActionSerializer
 
@@ -105,17 +128,17 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
 
     def _create_domain(self):
         serializer = serializers.DomainSerializer(
-            data={'name': self.authenticated_action.domain},
-            context=self.get_serializer_context()
+            data={"name": self.authenticated_action.domain},
+            context=self.get_serializer_context(),
         )
         try:
             serializer.is_valid(raise_exception=True)
         except ValidationError as e:  # e.g. domain name unavailable
             self.request.user.delete()
-            reasons = ', '.join([detail.code for detail in e.detail.get('name', [])])
+            reasons = ", ".join([detail.code for detail in e.detail.get("name", [])])
             raise ValidationError(
-                f'The requested domain {self.authenticated_action.domain} could not be registered (reason: {reasons}). '
-                f'Please start over and sign up again.'
+                f"The requested domain {self.authenticated_action.domain} could not be registered (reason: {reasons}). "
+                f"Please start over and sign up again."
             )
         # TODO the following line is subject to race condition and can fail, as for the domain name, we have that
         #  time-of-check != time-of-action
@@ -123,72 +146,96 @@ class AuthenticatedActivateUserActionView(AuthenticatedActionView):
 
     def _finalize_without_domain(self):
         if not is_password_usable(self.request.user.password):
-            serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(user=self.request.user)
-            return Response({'detail': 'Success! We sent you instructions on how to set your password.'})
-        return Response({'detail': 'Success! Your account has been activated, and you can now log in.'})
+            serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(
+                user=self.request.user
+            )
+            return Response(
+                {
+                    "detail": "Success! We sent you instructions on how to set your password."
+                }
+            )
+        return Response(
+            {
+                "detail": "Success! Your account has been activated, and you can now log in."
+            }
+        )
 
     def _finalize_with_domain(self, domain):
         if domain.is_locally_registrable:
             # TODO the following line raises Domain.DoesNotExist under unknown conditions
             PDNSChangeTracker.track(lambda: DomainViewSet.auto_delegate(domain))
-            token = Token.objects.create(user=domain.owner, name='dyndns')
-            return Response({
-                'detail': 'Success! Here is the password ("token") to configure your router (or any other dynDNS '
-                          'client). This password is different from your account password for security reasons.',
-                'domain': serializers.DomainSerializer(domain).data,
-                **serializers.TokenSerializer(token, include_plain=True).data,
-            })
+            token = Token.objects.create(user=domain.owner, name="dyndns")
+            return Response(
+                {
+                    "detail": 'Success! Here is the password ("token") to configure your router (or any other dynDNS '
+                    "client). This password is different from your account password for security reasons.",
+                    "domain": serializers.DomainSerializer(domain).data,
+                    **serializers.TokenSerializer(token, include_plain=True).data,
+                }
+            )
         else:
-            return Response({
-                'detail': 'Success! Please check the docs for the next steps, https://desec.readthedocs.io/.',
-                'domain': serializers.DomainSerializer(domain, include_keys=True).data,
-            })
+            return Response(
+                {
+                    "detail": "Success! Please check the docs for the next steps, https://desec.readthedocs.io/.",
+                    "domain": serializers.DomainSerializer(
+                        domain, include_keys=True
+                    ).data,
+                }
+            )
 
 
 class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/change-email/{code}/'
+    html_url = "/confirm/change-email/{code}/"
     serializer_class = serializers.AuthenticatedChangeEmailUserActionSerializer
 
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
-        return Response({
-            'detail': f'Success! Your email address has been changed to {self.authenticated_action.user.email}.'
-        })
+        return Response(
+            {
+                "detail": f"Success! Your email address has been changed to {self.authenticated_action.user.email}."
+            }
+        )
 
 
 class AuthenticatedConfirmAccountUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/confirm-account/{code}'
+    html_url = "/confirm/confirm-account/{code}"
     serializer_class = serializers.AuthenticatedConfirmAccountUserActionSerializer
 
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'Success! Your account status has been confirmed.'})
+        return Response({"detail": "Success! Your account status has been confirmed."})
 
 
 class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/reset-password/{code}/'
+    html_url = "/confirm/reset-password/{code}/"
     serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
 
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'Success! Your password has been changed.'})
+        return Response({"detail": "Success! Your password has been changed."})
 
 
 class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/delete-account/{code}/'
+    html_url = "/confirm/delete-account/{code}/"
     serializer_class = serializers.AuthenticatedDeleteUserActionSerializer
 
     def post(self, request, *args, **kwargs):
         if self.request.user.domains.exists():
             return AccountDeleteView.response_still_has_domains
         super().post(request, *args, **kwargs)
-        return Response({'detail': 'All your data has been deleted. Bye bye, see you soon! <3'})
+        return Response(
+            {"detail": "All your data has been deleted. Bye bye, see you soon! <3"}
+        )
 
 
 class AuthenticatedRenewDomainBasicUserActionView(AuthenticatedActionView):
-    html_url = '/confirm/renew-domain/{code}/'
+    html_url = "/confirm/renew-domain/{code}/"
     serializer_class = serializers.AuthenticatedRenewDomainBasicUserActionSerializer
 
     def post(self, request, *args, **kwargs):
         super().post(request, *args, **kwargs)
-        return Response({'detail': f'We recorded that your domain {self.authenticated_action.domain} is still in use.'})
+        return Response(
+            {
+                "detail": f"We recorded that your domain {self.authenticated_action.domain} is still in use."
+            }
+        )

+ 13 - 11
api/desecapi/views/base.py

@@ -19,20 +19,22 @@ class Root(APIView):
     def get(self, request, *args, **kwargs):
         if self.request.user.is_authenticated:
             routes = {
-                'account': {
-                    'show': reverse('account', request=request),
-                    'delete': reverse('account-delete', request=request),
-                    'change-email': reverse('account-change-email', request=request),
-                    'reset-password': reverse('account-reset-password', request=request),
+                "account": {
+                    "show": reverse("account", request=request),
+                    "delete": reverse("account-delete", request=request),
+                    "change-email": reverse("account-change-email", request=request),
+                    "reset-password": reverse(
+                        "account-reset-password", request=request
+                    ),
                 },
-                'logout': reverse('logout', request=request),
-                'tokens': reverse('token-list', request=request),
-                'domains': reverse('domain-list', request=request),
+                "logout": reverse("logout", request=request),
+                "tokens": reverse("token-list", request=request),
+                "domains": reverse("domain-list", request=request),
             }
         else:
             routes = {
-                'register': reverse('register', request=request),
-                'login': reverse('login', request=request),
-                'reset-password': reverse('account-reset-password', request=request),
+                "register": reverse("register", request=request),
+                "login": reverse("login", request=request),
+                "reset-password": reverse("account-reset-password", request=request),
             }
         return Response(routes)

+ 1 - 1
api/desecapi/views/captcha.py

@@ -5,4 +5,4 @@ from desecapi.serializers import CaptchaSerializer
 
 class CaptchaView(generics.CreateAPIView):
     serializer_class = CaptchaSerializer
-    throttle_scope = 'account_management_passive'
+    throttle_scope = "account_management_passive"

+ 24 - 16
api/desecapi/views/domains.py

@@ -14,20 +14,22 @@ from desecapi.serializers import DomainSerializer
 from .base import IdempotentDestroyMixin
 
 
-class DomainViewSet(IdempotentDestroyMixin,
-                    mixins.CreateModelMixin,
-                    mixins.RetrieveModelMixin,
-                    mixins.DestroyModelMixin,
-                    mixins.ListModelMixin,
-                    viewsets.GenericViewSet):
+class DomainViewSet(
+    IdempotentDestroyMixin,
+    mixins.CreateModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+    mixins.ListModelMixin,
+    viewsets.GenericViewSet,
+):
     serializer_class = DomainSerializer
-    lookup_field = 'name'
-    lookup_value_regex = r'[^/]+'
+    lookup_field = "name"
+    lookup_value_regex = r"[^/]+"
 
     @property
     def permission_classes(self):
         ret = [IsAuthenticated, permissions.IsOwner]
-        if self.action == 'create':
+        if self.action == "create":
             ret.append(permissions.WithinDomainLimit)
         if self.request.method not in SAFE_METHODS:
             ret.append(permissions.TokenNoDomainPolicy)
@@ -35,13 +37,17 @@ class DomainViewSet(IdempotentDestroyMixin,
 
     @property
     def throttle_scope(self):
-        return 'dns_api_read' if self.request.method in SAFE_METHODS else 'dns_api_write_domains'
+        return (
+            "dns_api_read"
+            if self.request.method in SAFE_METHODS
+            else "dns_api_write_domains"
+        )
 
     @property
     def pagination_class(self):
         # Turn off pagination when filtering for covered qname, as pagination would re-order by `created` (not what we
         # want here) after taking a slice (that's forbidden anyway). But, we don't need pagination in this case anyways.
-        if 'owns_qname' in self.request.query_params:
+        if "owns_qname" in self.request.query_params:
             return None
         else:
             return api_settings.DEFAULT_PAGINATION_CLASS
@@ -49,14 +55,14 @@ class DomainViewSet(IdempotentDestroyMixin,
     def get_queryset(self):
         qs = self.request.user.domains
 
-        owns_qname = self.request.query_params.get('owns_qname')
+        owns_qname = self.request.query_params.get("owns_qname")
         if owns_qname is not None:
-            qs = qs.filter_qname(owns_qname).order_by('-name_length')[:1]
+            qs = qs.filter_qname(owns_qname).order_by("-name_length")[:1]
 
         return qs
 
     def get_serializer(self, *args, **kwargs):
-        include_keys = (self.action in ['create', 'retrieve'])
+        include_keys = self.action in ["create", "retrieve"]
         return super().get_serializer(*args, include_keys=include_keys, **kwargs)
 
     def perform_create(self, serializer):
@@ -83,10 +89,12 @@ class DomainViewSet(IdempotentDestroyMixin,
 
 class SerialListView(APIView):
     permission_classes = (permissions.IsVPNClient,)
-    throttle_classes = []  # don't break slaves when they ask too often (our cached responses are cheap)
+    throttle_classes = (
+        []
+    )  # don't break slaves when they ask too often (our cached responses are cheap)
 
     def get(self, request, *args, **kwargs):
-        key = 'desecapi.views.serials'
+        key = "desecapi.views.serials"
         serials = cache.get(key)
         if serials is None:
             serials = get_serials()

+ 25 - 20
api/desecapi/views/donation.py

@@ -13,31 +13,36 @@ class DonationList(generics.CreateAPIView):
         instance = serializer.save()
 
         context = {
-            'donation': instance,
-            'creditoridentifier': settings.SEPA['CREDITOR_ID'],
-            'creditorname': settings.SEPA['CREDITOR_NAME'],
+            "donation": instance,
+            "creditoridentifier": settings.SEPA["CREDITOR_ID"],
+            "creditorname": settings.SEPA["CREDITOR_NAME"],
         }
 
         # internal desec notification
-        content_tmpl = get_template('emails/donation/desec-content.txt')
-        subject_tmpl = get_template('emails/donation/desec-subject.txt')
-        attachment_tmpl = get_template('emails/donation/desec-attachment-jameica.txt')
-        from_tmpl = get_template('emails/from.txt')
-        email = EmailMessage(subject_tmpl.render(context),
-                             content_tmpl.render(context),
-                             from_tmpl.render(context),
-                             [settings.DEFAULT_FROM_EMAIL],
-                             attachments=[('jameica-directdebit.xml', attachment_tmpl.render(context), 'text/xml')],
-                             reply_to=[instance.email] if instance.email else None
-                             )
+        content_tmpl = get_template("emails/donation/desec-content.txt")
+        subject_tmpl = get_template("emails/donation/desec-subject.txt")
+        attachment_tmpl = get_template("emails/donation/desec-attachment-jameica.txt")
+        from_tmpl = get_template("emails/from.txt")
+        email = EmailMessage(
+            subject_tmpl.render(context),
+            content_tmpl.render(context),
+            from_tmpl.render(context),
+            [settings.DEFAULT_FROM_EMAIL],
+            attachments=[
+                ("jameica-directdebit.xml", attachment_tmpl.render(context), "text/xml")
+            ],
+            reply_to=[instance.email] if instance.email else None,
+        )
         email.send()
 
         # donor notification
         if instance.email:
-            content_tmpl = get_template('emails/donation/donor-content.txt')
-            subject_tmpl = get_template('emails/donation/donor-subject.txt')
-            email = EmailMessage(subject_tmpl.render(context),
-                                 content_tmpl.render(context),
-                                 from_tmpl.render(context),
-                                 [instance.email])
+            content_tmpl = get_template("emails/donation/donor-content.txt")
+            subject_tmpl = get_template("emails/donation/donor-subject.txt")
+            email = EmailMessage(
+                subject_tmpl.render(context),
+                content_tmpl.render(context),
+                from_tmpl.render(context),
+                [instance.email],
+            )
             email.send()

+ 70 - 35
api/desecapi/views/dyndns.py

@@ -8,7 +8,11 @@ from rest_framework.exceptions import NotFound, ValidationError
 from rest_framework.response import Response
 
 from desecapi import metrics
-from desecapi.authentication import BasicTokenAuthentication, TokenAuthentication, URLParamAuthentication
+from desecapi.authentication import (
+    BasicTokenAuthentication,
+    TokenAuthentication,
+    URLParamAuthentication,
+)
 from desecapi.exceptions import ConcurrencyException
 from desecapi.models import Domain
 from desecapi.pdns_change_tracker import PDNSChangeTracker
@@ -18,11 +22,15 @@ from desecapi.serializers import RRsetSerializer
 
 
 class DynDNS12UpdateView(generics.GenericAPIView):
-    authentication_classes = (TokenAuthentication, BasicTokenAuthentication, URLParamAuthentication,)
+    authentication_classes = (
+        TokenAuthentication,
+        BasicTokenAuthentication,
+        URLParamAuthentication,
+    )
     permission_classes = (TokenHasDomainDynDNSPermission,)
     renderer_classes = [PlainTextRenderer]
     serializer_class = RRsetSerializer
-    throttle_scope = 'dyndns'
+    throttle_scope = "dyndns"
 
     @property
     def throttle_scope_bucket(self):
@@ -30,9 +38,9 @@ class DynDNS12UpdateView(generics.GenericAPIView):
 
     def _find_ip(self, params, version):
         if version == 4:
-            look_for = '.'
+            look_for = "."
         elif version == 6:
-            look_for = ':'
+            look_for = ":"
         else:
             raise Exception
 
@@ -45,7 +53,7 @@ class DynDNS12UpdateView(generics.GenericAPIView):
                     return self.request.query_params[p]
 
         # Check remote IP address
-        client_ip = self.request.META.get('REMOTE_ADDR')
+        client_ip = self.request.META.get("REMOTE_ADDR")
         if look_for in client_ip:
             return client_ip
 
@@ -56,29 +64,37 @@ class DynDNS12UpdateView(generics.GenericAPIView):
     def qname(self):
         # hostname parameter
         try:
-            if self.request.query_params['hostname'] != 'YES':
-                return self.request.query_params['hostname'].lower()
+            if self.request.query_params["hostname"] != "YES":
+                return self.request.query_params["hostname"].lower()
         except KeyError:
             pass
 
         # host_id parameter
         try:
-            return self.request.query_params['host_id'].lower()
+            return self.request.query_params["host_id"].lower()
         except KeyError:
             pass
 
         # http basic auth username
         try:
-            domain_name = base64.b64decode(
-                get_authorization_header(self.request).decode().split(' ')[1].encode()).decode().split(':')[0]
-            if domain_name and '@' not in domain_name:
+            domain_name = (
+                base64.b64decode(
+                    get_authorization_header(self.request)
+                    .decode()
+                    .split(" ")[1]
+                    .encode()
+                )
+                .decode()
+                .split(":")[0]
+            )
+            if domain_name and "@" not in domain_name:
                 return domain_name.lower()
         except (binascii.Error, IndexError, UnicodeDecodeError):
             pass
 
         # username parameter
         try:
-            return self.request.query_params['username'].lower()
+            return self.request.query_params["username"].lower()
         except KeyError:
             pass
 
@@ -86,40 +102,60 @@ class DynDNS12UpdateView(generics.GenericAPIView):
         try:
             return self.request.user.domains.get().name
         except Domain.MultipleObjectsReturned:
-            raise ValidationError(detail={
-                "detail": "Request does not properly specify domain for update.",
-                "code": "domain-unspecified"
-            })
+            raise ValidationError(
+                detail={
+                    "detail": "Request does not properly specify domain for update.",
+                    "code": "domain-unspecified",
+                }
+            )
         except Domain.DoesNotExist:
-            metrics.get('desecapi_dynDNS12_domain_not_found').inc()
-            raise NotFound('nohost')
+            metrics.get("desecapi_dynDNS12_domain_not_found").inc()
+            raise NotFound("nohost")
 
     @cached_property
     def domain(self):
         try:
-            return Domain.objects.filter_qname(self.qname, owner=self.request.user).order_by('-name_length')[0]
+            return Domain.objects.filter_qname(
+                self.qname, owner=self.request.user
+            ).order_by("-name_length")[0]
         except (IndexError, ValueError):
-            raise NotFound('nohost')
+            raise NotFound("nohost")
 
     @property
     def subname(self):
-        return self.qname.rpartition(f'.{self.domain.name}')[0]
+        return self.qname.rpartition(f".{self.domain.name}")[0]
 
     def get_serializer_context(self):
-        return {**super().get_serializer_context(), 'domain': self.domain, 'minimum_ttl': 60}
+        return {
+            **super().get_serializer_context(),
+            "domain": self.domain,
+            "minimum_ttl": 60,
+        }
 
     def get_queryset(self):
-        return self.domain.rrset_set.filter(subname=self.subname, type__in=['A', 'AAAA'])
+        return self.domain.rrset_set.filter(
+            subname=self.subname, type__in=["A", "AAAA"]
+        )
 
     def get(self, request, *args, **kwargs):
         instances = self.get_queryset().all()
 
-        ipv4 = self._find_ip(['myip', 'myipv4', 'ip'], version=4)
-        ipv6 = self._find_ip(['myipv6', 'ipv6', 'myip', 'ip'], version=6)
+        ipv4 = self._find_ip(["myip", "myipv4", "ip"], version=4)
+        ipv6 = self._find_ip(["myipv6", "ipv6", "myip", "ip"], version=6)
 
         data = [
-            {'type': 'A', 'subname': self.subname, 'ttl': 60, 'records': [ipv4] if ipv4 else []},
-            {'type': 'AAAA', 'subname': self.subname, 'ttl': 60, 'records': [ipv6] if ipv6 else []},
+            {
+                "type": "A",
+                "subname": self.subname,
+                "ttl": 60,
+                "records": [ipv4] if ipv4 else [],
+            },
+            {
+                "type": "AAAA",
+                "subname": self.subname,
+                "ttl": 60,
+                "records": [ipv6] if ipv6 else [],
+            },
         ]
 
         serializer = self.get_serializer(instances, data=data, many=True, partial=True)
@@ -127,12 +163,11 @@ class DynDNS12UpdateView(generics.GenericAPIView):
             serializer.is_valid(raise_exception=True)
         except ValidationError as e:
             if any(
-                    any(
-                        getattr(non_field_error, 'code', '') == 'unique'
-                        for non_field_error
-                        in err.get('non_field_errors', [])
-                    )
-                    for err in e.detail
+                any(
+                    getattr(non_field_error, "code", "") == "unique"
+                    for non_field_error in err.get("non_field_errors", [])
+                )
+                for err in e.detail
             ):
                 raise ConcurrencyException from e
             raise e
@@ -140,4 +175,4 @@ class DynDNS12UpdateView(generics.GenericAPIView):
         with PDNSChangeTracker():
             serializer.save()
 
-        return Response('good', content_type='text/plain')
+        return Response("good", content_type="text/plain")

+ 37 - 20
api/desecapi/views/records.py

@@ -31,42 +31,51 @@ class EmptyPayloadMixin:
 
 class RRsetView:
     serializer_class = RRsetSerializer
-    permission_classes = (IsAuthenticated, permissions.IsDomainOwner, permissions.TokenHasDomainRRsetsPermission,)
+    permission_classes = (
+        IsAuthenticated,
+        permissions.IsDomainOwner,
+        permissions.TokenHasDomainRRsetsPermission,
+    )
 
     @property
     def domain(self):
         try:
-            return self.request.user.domains.get(name=self.kwargs['name'])
+            return self.request.user.domains.get(name=self.kwargs["name"])
         except models.Domain.DoesNotExist:
             raise Http404
 
     @property
     def throttle_scope(self):
-        return 'dns_api_read' if self.request.method in SAFE_METHODS else 'dns_api_write_rrsets'
+        return (
+            "dns_api_read"
+            if self.request.method in SAFE_METHODS
+            else "dns_api_write_rrsets"
+        )
 
     @property
     def throttle_scope_bucket(self):
         # Note: bucket should remain constant even when domain is recreated
-        return None if self.request.method in SAFE_METHODS else self.kwargs['name']
+        return None if self.request.method in SAFE_METHODS else self.kwargs["name"]
 
     def get_queryset(self):
         return self.domain.rrset_set
 
     def get_serializer_context(self):
         # noinspection PyUnresolvedReferences
-        return {**super().get_serializer_context(), 'domain': self.domain}
+        return {**super().get_serializer_context(), "domain": self.domain}
 
     def perform_update(self, serializer):
         with PDNSChangeTracker():
             super().perform_update(serializer)
 
 
-class RRsetDetail(RRsetView, IdempotentDestroyMixin, generics.RetrieveUpdateDestroyAPIView):
-
+class RRsetDetail(
+    RRsetView, IdempotentDestroyMixin, generics.RetrieveUpdateDestroyAPIView
+):
     def get_object(self):
         queryset = self.filter_queryset(self.get_queryset())
 
-        filter_kwargs = {k: self.kwargs[k] for k in ['subname', 'type']}
+        filter_kwargs = {k: self.kwargs[k] for k in ["subname", "type"]}
         obj = generics.get_object_or_404(queryset, **filter_kwargs)
 
         # May raise a permission denied
@@ -86,22 +95,30 @@ class RRsetDetail(RRsetView, IdempotentDestroyMixin, generics.RetrieveUpdateDest
             super().perform_destroy(instance)
 
 
-class RRsetList(RRsetView, EmptyPayloadMixin, generics.ListCreateAPIView, generics.UpdateAPIView):
-
+class RRsetList(
+    RRsetView, EmptyPayloadMixin, generics.ListCreateAPIView, generics.UpdateAPIView
+):
     def get_queryset(self):
         rrsets = super().get_queryset()
 
-        for filter_field in ('subname', 'type'):
+        for filter_field in ("subname", "type"):
             value = self.request.query_params.get(filter_field)
 
             if value is not None:
                 # TODO consider moving this
-                if filter_field == 'type' and value in models.records.RR_SET_TYPES_AUTOMATIC:
-                    raise PermissionDenied("You cannot tinker with the %s RRset." % value)
+                if (
+                    filter_field == "type"
+                    and value in models.records.RR_SET_TYPES_AUTOMATIC
+                ):
+                    raise PermissionDenied(
+                        "You cannot tinker with the %s RRset." % value
+                    )
 
-                rrsets = rrsets.filter(**{'%s__exact' % filter_field: value})
+                rrsets = rrsets.filter(**{"%s__exact" % filter_field: value})
 
-        return rrsets.all()  # without .all(), cache is sometimes inconsistent with actual state in bulk tests. (Why?)
+        return (
+            rrsets.all()
+        )  # without .all(), cache is sometimes inconsistent with actual state in bulk tests. (Why?)
 
     def get_object(self):
         # For this view, the object we're operating on is the queryset that one can also GET. Serializing a queryset
@@ -113,11 +130,11 @@ class RRsetList(RRsetView, EmptyPayloadMixin, generics.ListCreateAPIView, generi
     def get_serializer(self, *args, **kwargs):
         kwargs = kwargs.copy()
 
-        if 'many' not in kwargs:
-            if self.request.method in ['POST']:
-                kwargs['many'] = isinstance(kwargs.get('data'), list)
-            elif self.request.method in ['PATCH', 'PUT']:
-                kwargs['many'] = True
+        if "many" not in kwargs:
+            if self.request.method in ["POST"]:
+                kwargs["many"] = isinstance(kwargs.get("data"), list)
+            elif self.request.method in ["PATCH", "PUT"]:
+                kwargs["many"] = True
 
         return super().get_serializer(*args, **kwargs)
 

+ 27 - 12
api/desecapi/views/tokens.py

@@ -16,16 +16,19 @@ from .domains import DomainViewSet
 
 class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
     serializer_class = TokenSerializer
-    permission_classes = (IsAuthenticated, permissions.HasManageTokensPermission,)
-    throttle_scope = 'account_management_passive'
+    permission_classes = (
+        IsAuthenticated,
+        permissions.HasManageTokensPermission,
+    )
+    throttle_scope = "account_management_passive"
 
     def get_queryset(self):
         return self.request.user.token_set.all()
 
     def get_serializer(self, *args, **kwargs):
         # When creating a new token, return the plaintext representation
-        if self.action == 'create':
-            kwargs.setdefault('include_plain', True)
+        if self.action == "create":
+            kwargs.setdefault("include_plain", True)
         return super().get_serializer(*args, **kwargs)
 
     def perform_create(self, serializer):
@@ -35,25 +38,35 @@ class TokenViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
 class TokenPoliciesRoot(APIView):
     permission_classes = [
         IsAuthenticated,
-        permissions.HasManageTokensPermission | permissions.AuthTokenCorrespondsToViewToken,
+        permissions.HasManageTokensPermission
+        | permissions.AuthTokenCorrespondsToViewToken,
     ]
 
     def get(self, request, *args, **kwargs):
-        return Response({'domain': reverse('token_domain_policies-list', request=request, kwargs=kwargs)})
+        return Response(
+            {
+                "domain": reverse(
+                    "token_domain_policies-list", request=request, kwargs=kwargs
+                )
+            }
+        )
 
 
 class TokenDomainPolicyViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
-    lookup_field = 'domain__name'
+    lookup_field = "domain__name"
     lookup_value_regex = DomainViewSet.lookup_value_regex
     pagination_class = None
     serializer_class = TokenDomainPolicySerializer
-    throttle_scope = 'account_management_passive'
+    throttle_scope = "account_management_passive"
 
     @property
     def permission_classes(self):
         ret = [IsAuthenticated]
         if self.request.method in SAFE_METHODS:
-            ret.append(permissions.HasManageTokensPermission | permissions.AuthTokenCorrespondsToViewToken)
+            ret.append(
+                permissions.HasManageTokensPermission
+                | permissions.AuthTokenCorrespondsToViewToken
+            )
         else:
             ret.append(permissions.HasManageTokensPermission)
         return ret
@@ -62,17 +75,19 @@ class TokenDomainPolicyViewSet(IdempotentDestroyMixin, viewsets.ModelViewSet):
         # map default policy onto domain_id IS NULL
         lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
         try:
-            if kwargs[lookup_url_kwarg] == 'default':
+            if kwargs[lookup_url_kwarg] == "default":
                 kwargs[lookup_url_kwarg] = None
         except KeyError:
             pass
         return super().dispatch(request, *args, **kwargs)
 
     def get_queryset(self):
-        return TokenDomainPolicy.objects.filter(token_id=self.kwargs['token_id'], token__user=self.request.user)
+        return TokenDomainPolicy.objects.filter(
+            token_id=self.kwargs["token_id"], token__user=self.request.user
+        )
 
     def perform_destroy(self, instance):
         try:
             super().perform_destroy(instance)
         except django.core.exceptions.ValidationError as exc:
-            raise ValidationError(exc.message_dict, code='precedence')
+            raise ValidationError(exc.message_dict, code="precedence")

部分文件因文件數量過多而無法顯示