Selaa lähdekoodia

feat(api): add API versioning, closes #137

Nils Wisiol 6 vuotta sitten
vanhempi
commit
35bc15c19e

+ 18 - 5
README.md

@@ -1,5 +1,5 @@
 deSEC Stack
-=====
+===========
 
 This is a docker-compose application providing the basic stack for deSEC name services. It consists of
 
@@ -11,7 +11,7 @@ This is a docker-compose application providing the basic stack for deSEC name se
 
 
 Requirements
------
+------------
 
 Although most configuration is contained in this repository, some external dependencies need to be met before the application can be run. Dependencies are:
 
@@ -61,7 +61,7 @@ Running the standard stack will also fire up an instance of the `www` proxy serv
 
 
 How to Run
------
+----------
 
 Development:
 
@@ -73,13 +73,26 @@ Production:
 
 
 Storage
----
+-------
 All important data is stored in the databases managed by the `db*` containers. They use Docker volumes which, by default, reside in `/var/lib/docker/volumes/desecstack_{dbapi,dblord,dbmaster}_mysql`.
 This is the location you will want to back up. (Be sure to follow standard MySQL backup practices, i.e. make sure things are consistent.)
 
 
+API Versions and Roadmap
+------------------------
+
+deSEC currently maintains the following API versions:
+
+API Version | URL Prefix | Status                                   | Support Ends
+----------- | ---------- | ---------------------------------------- | ------------
+Version 1   | `/api/v1/` |  unstable, stable release exp. June 2019 | earliest 6 months after v2 is declared stable
+Version 2   | `/api/v2/` |  unstable
+
+You can find our documentation for all API versions at https://desec.readthedocs.io/. (Select the version of interest in the navigation bar.)
+
+
 Notes on IPv6
------
+-------------
 
 This stack is IPv6-capable. Caveats:
 

+ 2 - 0
api/api/settings.py

@@ -88,6 +88,8 @@ REST_FRAMEWORK = {
     ),
     'TEST_REQUEST_DEFAULT_FORMAT': 'json',
     'EXCEPTION_HANDLER': 'desecapi.exception_handlers.handle_db_unavailable',
+    'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
+    'ALLOWED_VERSIONS': ['v1', 'v2'],
 }
 
 # user management configuration

+ 56 - 33
api/api/urls.py

@@ -1,38 +1,61 @@
-from django.conf.urls import include, url
-from desecapi import views
-from djoser.views import UserView
-from rest_framework.routers import SimpleRouter
+from django.urls import include, path
 
-tokens_router = SimpleRouter()
-tokens_router.register(r'', views.TokenViewSet, base_name='token')
 
-auth_urls = [
-    url(r'^users/create/$', views.UserCreateView.as_view(), name='user-create'),  # deprecated
-    url(r'^token/create/$', views.TokenCreateView.as_view(), name='token-create'),  # deprecated
-    url(r'^token/destroy/$', views.TokenDestroyView.as_view(), name='token-destroy'),  # deprecated
-    url(r'^users/$', views.UserCreateView.as_view(), name='register'),
-    url(r'^token/login/$', views.TokenCreateView.as_view(), name='login'),
-    url(r'^token/logout/$', views.TokenDestroyView.as_view(), name='logout'),
-    url(r'^tokens/', include(tokens_router.urls)),
-    url(r'^me/?$', UserView.as_view(), name='user'),
-    url(r'^', include('djoser.urls.authtoken')),
-]
-
-api_urls = [
-    url(r'^$', views.Root.as_view(), name='root'),
-    url(r'^domains/$', views.DomainList.as_view(), name='domain-list'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/$', views.DomainDetail.as_view(), name='domain-detail'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/$', views.RRsetList.as_view(), name='rrsets'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>(\*)?[a-zA-Z\.\-_0-9=]*)\.\.\./(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset'),
-    url(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>[*@]|[a-zA-Z\.\-_0-9=]+)/(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset@'),
-    url(r'^dns$', views.DnsQuery.as_view(), name='dns-query'),
-    url(r'^dyndns/update$', views.DynDNS12Update.as_view(), name='dyndns12update'),
-    url(r'^donation/', views.DonationList.as_view(), name='donation'),
-    url(r'^unlock/user/(?P<email>.+)$', views.unlock, name='unlock/byEmail'),
-    url(r'^unlock/done', views.unlock_done, name='unlock/done'),
-]
+#
+# On Reversing URLs
+# =================
+#
+# Recommended Usage:
+# Always use rest_framework.reverse.reverse, do not directly use django.urls.reverse.
+# If a request object r is available, use reverse(name, request=r). With the name as defined
+# in desecapi.urls.v1 or desecapi.urls.v2. It will return an URL maintaining the currently requested API version.
+# If there is no request object available, e.g. in commands, a mock object can be constructed
+# carrying all information that is necessary to construct a full URL:
+#
+#         from django.test import RequestFactory
+#         from rest_framework.versioning import NamespaceVersioning
+#         from api import settings
+#
+#         r = RequestFactory().request(HTTP_HOST=settings.ALLOWED_HOSTS[0])
+#         r.version = 'v1'
+#         r.versioning_scheme = NamespaceVersioning()
+#
+# Also note in this context settings.REST_FRAMEWORK['ALLOWED_VERSIONS'] and
+# settings.REST_FRAMEWORK['DEFAULT_VERSIONING_CLASS']. (The latter is of type string.)
+#
+# Advanced Usage:
+# Prefix the name of any path with 'desecapi' to get the default version,
+# or prefix the name of any path with the desired namespace, e.g. 'v1:root'.
+# In this case, the version information of the request will be ignored and
+# providing a request object is optional. However, if no request object is provided,
+# only a relative URL can be generated.
+#
+# Examples:
+# The examples refer to the version used by the client to connect as the REQUESTED version,
+# the version specified by the first argument to reverse as the SPECIFIED version, and to the
+# version defined as default (see below) as the DEFAULT version.
+#
+#         reverse('root', request) -> absolute URL, e.g. https://.../api/v1/, with the REQUESTED version
+#         reverse('root') -> django.urls.exceptions.NoReverseMatch
+#         reverse('desecapi:root') -> relative URL, e.g. api/v1/, with the DEFAULT version
+#         reverse('v2:root') -> relative URL, e.g. api/v2/, with the SPECIFIED version
+#         reverse('v2:root', request) -> absolute URL, e.g. https://.../api/v2/, with the SPECIFIED version
+#         reverse('desecapi:root', request) -> absolute URL, e.g. https://.../api/v1/, with the DEFAULT version
+#         reverse('v1:root', request) -> absolute URL, e.g. https://.../api/v1/, with the SPECIFIED version
+#
+# See Also:
+# https://github.com/encode/django-rest-framework/issues/5659
+# https://github.com/encode/django-rest-framework/issues/3825
+#
+# Note that from the client's perspective, there is no default version: each request needs to
+# specify the version in the request URL.
+#
 
+# IMPORTANT: specify default version as the last element in the list
+# if no other information is available, the last-specified version will be used as default for reversing URLs
 urlpatterns = [
-    url(r'^api/v1/auth/', include(auth_urls)),
-    url(r'^api/v1/', include(api_urls)),
+    # other available versions in no particular order
+    path('api/v2/', include('desecapi.urls.version_2', namespace='v2')),
+    # the DEFAULT version
+    path('api/v1/', include('desecapi.urls.version_1', namespace='v1')),
 ]

+ 28 - 28
api/desecapi/tests/testdomains.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 from desecapi.models import Domain
 from django.core import mail
 import httpretty
@@ -11,22 +11,22 @@ import json
 
 class UnauthenticatedDomainTests(APITestCase):
     def testExpectUnauthorizedOnGet(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
     def testExpectUnauthorizedOnPost(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.post(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
     def testExpectUnauthorizedOnPut(self):
-        url = reverse('domain-detail', args=('example.com',))
+        url = reverse('v1:domain-detail', args=('example.com',))
         response = self.client.put(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
     def testExpectUnauthorizedOnDelete(self):
-        url = reverse('domain-detail', args=('example.com',))
+        url = reverse('v1:domain-detail', args=('example.com',))
         response = self.client.delete(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
@@ -45,7 +45,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.disable()
 
     def testExpectOnlyOwnedDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 2)
@@ -57,7 +57,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name+ '.')
 
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertEqual(httpretty.last_request().method, 'DELETE')
@@ -76,7 +76,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
 
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
@@ -89,19 +89,19 @@ class AuthenticatedDomainTests(APITestCase):
                                body='[]',
                                content_type="application/json")
 
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['name'], self.ownedDomains[1].name)
         self.assertTrue(isinstance(response.data['keys'], list))
 
     def testCantGetOtherDomains(self):
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCantChangeDomainName(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.get(url)
         newname = utils.generateDomainname()
         response.data['name'] = newname
@@ -112,12 +112,12 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertEqual(response.data['name'], self.ownedDomains[1].name)
 
     def testCantPutOtherDomains(self):
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.put(url, json.dumps({}), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCanPostDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': utils.generateDomainname()}
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -138,14 +138,14 @@ class AuthenticatedDomainTests(APITestCase):
                                content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': name}
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         self.assertEqual(len(mail.outbox), 0)
 
     def testCantPostDomainAlreadyTakenInAPI(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
 
         data = {'name': utils.generateDomainname()}
         response = self.client.post(url, data)
@@ -168,13 +168,13 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones',
                                body='{"error": "Domain \'' + name + '.\' already exists"}', status=422)
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': name}
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
     def testCantPostDomainsViolatingPolicy(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
 
         data = {'name': '*.' + utils.generateDomainname()}
         response = self.client.post(url, data)
@@ -182,7 +182,7 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertTrue("does not match the required pattern." in response.data['name'][0])
 
     def testCanPostComplicatedDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': 'very.long.domain.name.' + utils.generateDomainname()}
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -202,7 +202,7 @@ class AuthenticatedDomainTests(APITestCase):
                                content_type="application/json")
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         self.client.post(url, {'name': name})
 
         self.assertEqual(httpretty.httpretty.latest_requests[-4].method, 'POST')
@@ -213,7 +213,7 @@ class AuthenticatedDomainTests(APITestCase):
         self.assertTrue((settings.NSLORD_PDNS_API + '/zones/' + name + '.').endswith(httpretty.httpretty.latest_requests[-2].path))
 
     def testDomainDetailURL(self):
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         self.assertTrue("/" + self.ownedDomains[1].name in url)
 
     def testRollback(self):
@@ -222,7 +222,7 @@ class AuthenticatedDomainTests(APITestCase):
         httpretty.enable()
         httpretty.register_uri(httpretty.POST, settings.NSLORD_PDNS_API + '/zones', body="some error", status=500)
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': name}
         self.client.post(url, data)
 
@@ -247,7 +247,7 @@ class AuthenticatedDynDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
 
-        url = reverse('domain-detail', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.ownedDomains[1].name,))
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
@@ -268,14 +268,14 @@ class AuthenticatedDynDomainTests(APITestCase):
         httpretty.register_uri(httpretty.DELETE, settings.NSLORD_PDNS_API + '/zones/' + self.otherDomains[1].name + '.')
         httpretty.register_uri(httpretty.DELETE, settings.NSMASTER_PDNS_API + '/zones/' + self.otherDomains[1].name+ '.')
 
-        url = reverse('domain-detail', args=(self.otherDomains[1].name,))
+        url = reverse('v1:domain-detail', args=(self.otherDomains[1].name,))
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
         self.assertTrue(isinstance(httpretty.last_request(), httpretty.core.HTTPrettyRequestEmpty))
         self.assertTrue(Domain.objects.filter(pk=self.otherDomains[1].pk).exists())
 
     def testCanPostDynDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': utils.generateDynDomainname()}
         response = self.client.post(url, data)
         email = str(mail.outbox[0].message())
@@ -289,7 +289,7 @@ class AuthenticatedDynDomainTests(APITestCase):
         # it is currently not covered.
 
     def testCantPostNonDynDomains(self):
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
 
         data = {'name': utils.generateDomainname()}
         response = self.client.post(url, data)
@@ -308,7 +308,7 @@ class AuthenticatedDynDomainTests(APITestCase):
 
         outboxlen = len(mail.outbox)
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         for i in range(settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2):
             name = utils.generateDynDomainname()
 
@@ -352,7 +352,7 @@ class AuthenticatedDynDomainTests(APITestCase):
             'at@sign.com',
         ]
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         for domainname in invalidnames:
             data = {'name': domainname}
             response = self.client.post(url, data)

+ 6 - 10
api/desecapi/tests/testdonations.py

@@ -1,35 +1,31 @@
 # coding: utf-8
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
-from .utils import utils
-from django.db import transaction
-from desecapi.models import Domain
+from desecapi.tests.utils import utils
 from django.core import mail
-import httpretty
-from django.conf import settings
 
 
 class UnsuccessfulDonationTests(APITestCase):
     def testExpectUnauthorizedOnGet(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 
     def testExpectUnauthorizedOnPut(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         response = self.client.put(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 
     def testExpectUnauthorizedOnDelete(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         response = self.client.delete(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
 
 
 class SuccessfulDonationTests(APITestCase):
     def testCanPostDonations(self):
-        url = reverse('donation')
+        url = reverse('v1:donation')
         data = \
             {
                 'name': 'Komplizierter Vörnämü-ßßß 马大为',

+ 22 - 22
api/desecapi/tests/testdyndns12update.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 import base64
 import httpretty
 from django.conf import settings
@@ -22,7 +22,7 @@ class DynDNS12UpdateTest(APITestCase):
         self.domain = utils.generateDynDomainname()
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         data = {'name': self.domain}
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -59,7 +59,7 @@ class DynDNS12UpdateTest(APITestCase):
         name = name or self.username
 
         def verify_response(type_, ip):
-            url = reverse('rrset', args=(name, '', type_,))
+            url = reverse('v1:rrset', args=(name, '', type_,))
             response = self.client.get(url)
 
             if ip is not None:
@@ -76,7 +76,7 @@ class DynDNS12UpdateTest(APITestCase):
 
     def testDynDNS1UpdateDDClientSuccess(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myip=10.1.2.3
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
                                    {
                                        'action': 'edit',
@@ -91,7 +91,7 @@ class DynDNS12UpdateTest(APITestCase):
 
     def testDynDNS1UpdateDDClientIPv6Success(self):
         # /nic/dyndns?action=edit&started=1&hostname=YES&host_id=foobar.dedyn.io&myipv6=::1337
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
                                    {
                                        'action': 'edit',
@@ -106,7 +106,7 @@ class DynDNS12UpdateTest(APITestCase):
 
     def testDynDNS2UpdateDDClientIPv4Success(self):
         #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myip=10.2.3.4
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
                                    {
                                        'system': 'dyndns',
@@ -119,7 +119,7 @@ class DynDNS12UpdateTest(APITestCase):
 
     def testDynDNS2UpdateDDClientIPv6Success(self):
         #/nic/update?system=dyndns&hostname=foobar.dedyn.io&myipv6=::1338
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
                                    {
                                        'system': 'dyndns',
@@ -132,14 +132,14 @@ class DynDNS12UpdateTest(APITestCase):
 
     def testFritzBox(self):
         #/
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='127.0.0.1')
 
     def testUnsetIP(self):
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
 
         def testVariant(params, **kwargs):
             response = self.client.get(url, params)
@@ -168,25 +168,25 @@ class DynDNS12UpdateTest(APITestCase):
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.post(url, {'name': name})
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
         self.assertIP(ipv4='10.5.5.5')
 
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + '.invalid:' + self.password).encode()).decode())
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.5')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testIdentificationByTokenWithEmptyUser(self):
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((':' + self.password).encode()).decode())
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.6')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
@@ -207,18 +207,18 @@ class DynDNS12UpdateTest(APITestCase):
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + name + './notify', status=200)
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         response = self.client.post(url, {'name': name})
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url, REMOTE_ADDR='10.5.5.7')
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
     def testManual(self):
         #/update?username=foobar.dedyn.io&password=secret
         self.client.credentials(HTTP_AUTHORIZATION='')
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
                                    {
                                        'username': self.username,
@@ -232,7 +232,7 @@ class DynDNS12UpdateTest(APITestCase):
         # The dynamic update will try to set the TTL to 60. Here, we create
         # a record with a different TTL beforehand and then make sure that
         # updates still work properly.
-        url = reverse('rrsets', args=(self.domain,))
+        url = reverse('v1:rrsets', args=(self.domain,))
         data = {'records': ['127.0.0.1'], 'ttl': 3600, 'type': 'A'}
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         response = self.client.post(url, json.dumps(data),
@@ -241,7 +241,7 @@ class DynDNS12UpdateTest(APITestCase):
 
         self.httpretty_reset_uris()
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
         response = self.client.get(url)
         self.assertEqual(httpretty.httpretty.latest_requests[-2].method, 'PATCH')
@@ -254,7 +254,7 @@ class DynDNS12UpdateTest(APITestCase):
         self.owner.locked = timezone.now()
         self.owner.save()
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
                                    {
                                        'system': 'dyndns',
@@ -289,7 +289,7 @@ class DynDNS12UpdateTest(APITestCase):
         self.owner.save()
 
         # While in locked state, create a domain and set some records
-        url = reverse('domain-list')
+        url = reverse('v1:domain-list')
         newdomain = utils.generateDynDomainname()
 
         data = {'name': newdomain}
@@ -301,7 +301,7 @@ class DynDNS12UpdateTest(APITestCase):
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('dyndns12update')
+        url = reverse('v1:dyndns12update')
         response = self.client.get(url,
                                    {
                                        'system': 'dyndns',

+ 4 - 4
api/desecapi/tests/testdynupdateauthentication.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 import httpretty
 import base64
 from django.conf import settings
@@ -20,11 +20,11 @@ class DynUpdateAuthenticationTests(APITestCase):
             self.user = utils.createUser(self.username, self.password)
             self.token = utils.createToken(user=self.user)
             self.setCredentials(self.username, self.password)
-            self.url = reverse('dyndns12update')
+            self.url = reverse('v1:dyndns12update')
 
             self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
             self.domain = utils.generateDynDomainname()
-            url = reverse('domain-list')
+            url = reverse('v1:domain-list')
             data = {'name': self.domain}
             response = self.client.post(url, data)
             self.assertEqual(response.status_code, status.HTTP_201_CREATED)

+ 23 - 17
api/desecapi/tests/testregistration.py

@@ -1,7 +1,10 @@
-from django.urls import reverse
+from django.test import RequestFactory
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
-from .utils import utils
+from rest_framework.versioning import NamespaceVersioning
+
+from desecapi.tests.utils import utils
 from desecapi import models
 from datetime import timedelta
 from django.utils import timezone
@@ -13,7 +16,7 @@ from api import settings
 class RegistrationTest(APITestCase):
 
     def test_registration_successful(self):
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -24,7 +27,7 @@ class RegistrationTest(APITestCase):
     def test_multiple_registration_locked_same_ip_short_time(self):
         outboxlen = len(mail.outbox)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
@@ -36,7 +39,7 @@ class RegistrationTest(APITestCase):
 
         self.assertEqual(len(mail.outbox), outboxlen)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
@@ -48,7 +51,7 @@ class RegistrationTest(APITestCase):
 
         self.assertEqual(len(mail.outbox), outboxlen + 1)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
@@ -61,7 +64,7 @@ class RegistrationTest(APITestCase):
         self.assertEqual(len(mail.outbox), outboxlen + 2)
 
     def test_multiple_registration_not_locked_different_ip(self):
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.8")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -70,7 +73,7 @@ class RegistrationTest(APITestCase):
         self.assertEqual(user.registration_remote_ip, "1.3.3.8")
         self.assertIsNone(user.locked)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.9")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -80,7 +83,7 @@ class RegistrationTest(APITestCase):
         self.assertIsNone(user.locked)
 
     def test_multiple_registration_not_locked_same_ip_long_time(self):
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -93,7 +96,7 @@ class RegistrationTest(APITestCase):
         user.created = timezone.now() - timedelta(hours=settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS+1)
         user.save()
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -105,20 +108,23 @@ class RegistrationTest(APITestCase):
     def test_send_captcha_email_manually(self):
         outboxlen = len(mail.outbox)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {'email': utils.generateUsername(),
                 'password': utils.generateRandomString(size=12), 'dyn': True}
         response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
         user = models.User.objects.get(email=data['email'])
-        send_account_lock_email(None, user)
+        r = RequestFactory().request(HTTP_HOST=settings.ALLOWED_HOSTS[0])
+        r.version = 'v1'
+        r.versioning_scheme = NamespaceVersioning()
+        send_account_lock_email(r, user)
 
         self.assertEqual(len(mail.outbox), outboxlen+1)
 
     def test_multiple_registration_locked_same_email_host(self):
         outboxlen = len(mail.outbox)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
             data = {
                 'email': utils.generateRandomString() + '@test-same-email.desec.io',
@@ -133,7 +139,7 @@ class RegistrationTest(APITestCase):
 
         self.assertEqual(len(mail.outbox), outboxlen)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {
             'email': utils.generateRandomString() + '@test-same-email.desec.io',
             'password': utils.generateRandomString(size=12),
@@ -150,7 +156,7 @@ class RegistrationTest(APITestCase):
     def test_multiple_registration_not_locked_same_email_host_long_time(self):
         outboxlen = len(mail.outbox)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         for i in range(settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT):
             data = {
                 'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
@@ -170,7 +176,7 @@ class RegistrationTest(APITestCase):
 
         self.assertEqual(len(mail.outbox), outboxlen)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {
             'email': utils.generateRandomString() + '@test-same-email-1.desec.io',
             'password': utils.generateRandomString(size=12),
@@ -187,7 +193,7 @@ class RegistrationTest(APITestCase):
     def test_token_email(self):
         outboxlen = len(mail.outbox)
 
-        url = reverse('register')
+        url = reverse('v1:register')
         data = {
             'email': utils.generateRandomString() + '@test-same-email.desec.io',
             'password': utils.generateRandomString(size=12),

+ 67 - 67
api/desecapi/tests/testrrsets.py

@@ -1,7 +1,7 @@
-from django.urls import reverse
+from rest_framework.reverse import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
-from .utils import utils
+from desecapi.tests.utils import utils
 import httpretty
 from django.conf import settings
 import json
@@ -11,22 +11,22 @@ from django.utils import timezone
 
 class UnauthenticatedDomainTests(APITestCase):
     def testExpectUnauthorizedOnGet(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.get(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
     def testExpectUnauthorizedOnPost(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.post(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
     def testExpectUnauthorizedOnPut(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.put(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
     def testExpectUnauthorizedOnDelete(self):
-        url = reverse('rrsets', args=('example.com',))
+        url = reverse('v1:rrsets', args=('example.com',))
         response = self.client.delete(url, format='json')
         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
 
@@ -50,24 +50,24 @@ class AuthenticatedRRsetTests(APITestCase):
             self.otherToken = utils.createToken(user=self.otherOwner)
 
     def testCanGetOwnRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 1) # don't forget NS RRset
 
     def testCantGetForeignRRsets(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCanGetOwnRRsetsEmptySubname(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         response = self.client.get(url + '?subname=')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 1) # don't forget NS RRset
 
     def testCanGetOwnRRsetsFromSubname(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -90,12 +90,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(len(response.data), 2)
 
     def testCantGetForeignRRsetsFromSubname(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         response = self.client.get(url + '?subname=test')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCanGetOwnRRsetsFromType(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -118,12 +118,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(len(response.data), 2)
 
     def testCantGetForeignRRsetsFromType(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         response = self.client.get(url + '?test=A')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCanPostOwnRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
@@ -132,18 +132,18 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 1 + 1) # don't forget NS RRset
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['desec.io.'], 'ttl': 900, 'type': 'PTR'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
     def testCantPostEmptyRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': [], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -154,37 +154,37 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCantPostDeadTypes(self):
         for type_ in self.dead_types:
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
             data = {'records': ['www.example.com.'], 'ttl': 60, 'type': type_}
             response = self.client.post(url, json.dumps(data), content_type='application/json')
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
     def testCantPostRestrictedTypes(self):
         for type_ in self.restricted_types:
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
             data = {'records': ['ns1.desec.io. peter.desec.io. 2584 10800 3600 604800 60'], 'ttl': 60, 'type': type_}
             response = self.client.post(url, json.dumps(data), content_type='application/json')
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
     def testCantPostForeignRRsets(self):
-        url = reverse('rrsets', args=(self.otherDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCantPostTwiceRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['3.2.2.1'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
 
     def testCantPostFaultyRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
         # New record without a value is a syntactical error --> 400
         data = {'records': [], 'ttl': 60, 'type': 'TXT'}
@@ -197,30 +197,30 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
         # Unknown type is a semantical error --> 422
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['123456'], 'ttl': 60, 'type': 'AA'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY)
 
     def testCanGetOwnRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
         self.assertEqual(response.data['ttl'], 60)
 
     def testCanGetOwnRRsetApex(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
@@ -228,28 +228,28 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCantGetRestrictedTypes(self):
         for type_ in self.restricted_types:
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
             response = self.client.get(url + '?type=%s' % type_)
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
-            url = reverse('rrset', args=(self.ownedDomains[1].name, '', type_,))
+            url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', type_,))
             response = self.client.get(url)
             self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
 
     def testCantGetForeignRRset(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCanGetOwnRRsetWithSubname(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
         data = {'records': ['1.2.3.4'], 'ttl': 120, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -267,7 +267,7 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(len(response.data), 3 + 1) # don't forget NS RRset
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, 'test', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, 'test', 'A',))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '2.2.3.4')
@@ -276,7 +276,7 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCanGetOwnRRsetWithWildcard(self):
         for subname in ('*', '*.foobar'):
-            url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+            url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
 
             data = {'records': ['"barfoo"'], 'ttl': 120, 'type': 'TXT', 'subname': subname}
             response = self.client.post(url, json.dumps(data), content_type='application/json')
@@ -288,17 +288,17 @@ class AuthenticatedRRsetTests(APITestCase):
             self.assertEqual(response1.data[0]['ttl'], 120)
             self.assertEqual(response1.data[0]['name'], subname + '.' + self.ownedDomains[1].name + '.')
 
-            url = reverse('rrset', args=(self.ownedDomains[1].name, subname, 'TXT',))
+            url = reverse('v1:rrset', args=(self.ownedDomains[1].name, subname, 'TXT',))
             response2 = self.client.get(url)
             self.assertEqual(response2.data, response1.data[0])
 
     def testCanPutOwnRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
 
         data = {'records': ['2.2.3.4'], 'ttl': 30}
         response = self.client.put(url, json.dumps(data), content_type='application/json')
@@ -318,12 +318,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
     def testCanPutOwnRRsetApex(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
 
         data = {'records': ['2.2.3.4'], 'ttl': 30}
         response = self.client.put(url, json.dumps(data), content_type='application/json')
@@ -343,13 +343,13 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 
     def testCanPatchOwnRRset(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         # Change records and TTL
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 32}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -381,13 +381,13 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.data['ttl'], 37)
 
     def testCanPatchOwnRRsetApex(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         # Change records and TTL
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 32}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -420,13 +420,13 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCantChangeForeignRRset(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
 
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
@@ -437,13 +437,13 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCantChangeForeignRRsetApex(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset@', args=(self.otherDomains[0].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.otherDomains[0].name, '@', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 30, 'type': 'A'}
 
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
@@ -453,13 +453,13 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
     def testCantChangeEssentialProperties(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A', 'subname': 'test1'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         # Changing the subname is expected to cause an error
-        url = reverse('rrset', args=(self.ownedDomains[1].name, 'test1', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, 'test1', 'A',))
         data = {'records': ['3.2.3.4'], 'ttl': 120, 'subname': 'test2'}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@@ -495,12 +495,12 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCanDeleteOwnRRset(self):
         # Try PATCH with empty records
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         data = {'records': []}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
@@ -509,12 +509,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
         # Try DELETE
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
@@ -523,12 +523,12 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCanDeleteOwnRRsetApex(self):
         # Try PATCH with empty records
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         data = {'records': []}
         response = self.client.patch(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
@@ -537,12 +537,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
 
         # Try DELETE
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.ownedDomains[1].name, '@', 'A',))
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
@@ -551,13 +551,13 @@ class AuthenticatedRRsetTests(APITestCase):
 
     def testCantDeleteForeignRRset(self):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrsets', args=(self.otherDomains[0].name,))
+        url = reverse('v1:rrsets', args=(self.otherDomains[0].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = reverse('rrset', args=(self.otherDomains[0].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.otherDomains[0].name, '', 'A',))
 
         # Try PATCH with empty records
         data = {'records': []}
@@ -570,7 +570,7 @@ class AuthenticatedRRsetTests(APITestCase):
 
         # Make sure it actually is still there
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.otherToken)
-        url = reverse('rrset@', args=(self.otherDomains[0].name, '@', 'A',))
+        url = reverse('v1:rrset@', args=(self.otherDomains[0].name, '@', 'A',))
         response = self.client.get(url)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data['records'][0], '1.2.3.4')
@@ -579,12 +579,12 @@ class AuthenticatedRRsetTests(APITestCase):
         self.owner.locked = timezone.now()
         self.owner.save()
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)
 
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
 
         # Try PATCH with empty records
         data = {'records': []}
@@ -600,7 +600,7 @@ class AuthenticatedRRsetTests(APITestCase):
         httpretty.register_uri(httpretty.PATCH, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + '.')
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
 
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         self.client.post(url, json.dumps(data), content_type='application/json')
 
@@ -615,12 +615,12 @@ class AuthenticatedRRsetTests(APITestCase):
         httpretty.register_uri(httpretty.PUT, settings.NSLORD_PDNS_API + '/zones/' + self.ownedDomains[1].name + './notify')
 
         # Create record, should cause a pdns PATCH request and a notify
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         self.client.post(url, json.dumps(data), content_type='application/json')
 
         # Delete record, should cause a pdns PATCH request and a notify
-        url = reverse('rrset', args=(self.ownedDomains[1].name, '', 'A',))
+        url = reverse('v1:rrset', args=(self.ownedDomains[1].name, '', 'A',))
         response = self.client.delete(url)
         self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
 
@@ -637,7 +637,7 @@ class AuthenticatedRRsetTests(APITestCase):
         self.assertEqual(httpretty.httpretty.latest_requests[-1].method, 'PUT')
 
     def testImportRRsets(self):
-        url = reverse('rrsets', args=(self.ownedDomains[1].name,))
+        url = reverse('v1:rrsets', args=(self.ownedDomains[1].name,))
         data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
         response = self.client.post(url, json.dumps(data), content_type='application/json')
         self.assertEqual(response.status_code, status.HTTP_201_CREATED)

+ 61 - 0
api/desecapi/urls/version_1.py

@@ -0,0 +1,61 @@
+from django.urls import include, path, re_path
+from djoser.views import UserView
+from rest_framework.routers import SimpleRouter
+
+from desecapi import views
+
+
+tokens_router = SimpleRouter()
+tokens_router.register(r'', views.TokenViewSet, base_name='token')
+
+auth_urls = [
+    # Old user management
+    # TODO deprecated, remove
+    path('users/create/', views.UserCreateView.as_view(), name='user-create'),  # deprecated
+    path('token/create/', views.TokenCreateView.as_view(), name='token-create'),  # deprecated
+    path('token/destroy/', views.TokenDestroyView.as_view(), name='token-destroy'),  # deprecated
+
+    # New user management
+    path('users/', views.UserCreateView.as_view(), name='register'),
+
+    # Token management
+    path('token/login/', views.TokenCreateView.as_view(), name='login'),
+    path('token/logout/', views.TokenDestroyView.as_view(), name='logout'),
+    path('', include('djoser.urls.authtoken')),  # note: this is partially overwritten by the two lines above
+    path('tokens/', include(tokens_router.urls)),
+
+    # User home
+    path('me/', UserView.as_view(), name='user'),
+]
+
+api_urls = [
+    # API home
+    path('', views.Root.as_view(), name='root'),
+
+    # Domain and RRSet endpoints
+    path('domains/', views.DomainList.as_view(), name='domain-list'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/$', views.DomainDetail.as_view(), name='domain-detail'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/$', views.RRsetList.as_view(), name='rrsets'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>(\*)?[a-zA-Z\.\-_0-9=]*)\.\.\./(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset'),
+    re_path(r'^domains/(?P<name>[a-zA-Z\.\-_0-9]+)/rrsets/(?P<subname>[*@]|[a-zA-Z\.\-_0-9=]+)/(?P<type>[A-Z][A-Z0-9]*)/$', views.RRsetDetail.as_view(), name='rrset@'),
+
+    # DNS query endpoint
+    # TODO remove?
+    path('dns', views.DnsQuery.as_view(), name='dns-query'),
+
+    # DynDNS update endpoint
+    path('dyndns/update', views.DynDNS12Update.as_view(), name='dyndns12update'),
+
+    # Donation endpoints
+    path('donation/', views.DonationList.as_view(), name='donation'),
+
+    # Unlock endpoints
+    path('unlock/user/<email>', views.unlock, name='unlock/byEmail'),
+    path('unlock/done', views.unlock_done, name='unlock/done'),
+]
+
+app_name = 'desecapi'
+urlpatterns = [
+    path('auth/', include(auth_urls)),
+    path('', include(api_urls)),
+]

+ 4 - 0
api/desecapi/urls/version_2.py

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

+ 6 - 6
api/desecapi/views.py

@@ -297,14 +297,14 @@ class Root(APIView):
     def get(self, request, format=None):
         if self.request.user and self.request.user.is_authenticated:
             return Response({
-                'domains': reverse('domain-list'),
-                'user': reverse('user'),
-                'logout': reverse('token-destroy'),  # TODO change interface to token-destroy, too?
+                'domains': reverse('domain-list', request=request),
+                'user': reverse('user', request=request),
+                'logout': reverse('token-destroy', request=request),  # TODO change interface to token-destroy, too?
             })
         else:
             return Response({
-                'login': reverse('token-create', request=request, format=format),  # TODO change interface to token-create, too?
-                'register': reverse('register', request=request, format=format),
+                'login': reverse('token-create', request=request),
+                'register': reverse('register', request=request),
             })
 
 
@@ -546,7 +546,7 @@ def unlock(request, email):
                 # fail silently, so people can't probe registered addresses
                 pass
 
-            return HttpResponseRedirect(reverse('unlock/done'))
+            return HttpResponseRedirect(reverse('unlock/done', request=request))
 
     # if a GET (or any other method) we'll create a blank form
     else:

+ 1 - 0
docs/index.rst

@@ -3,6 +3,7 @@
 .. include:: domains.rst
 .. include:: rrsets.rst
 .. include:: endpoint-reference.rst
+.. include:: lifecycle.rst
 
 Getting Help
 ------------

+ 39 - 0
docs/lifecycle.rst

@@ -0,0 +1,39 @@
+API Versions and Lifecycle
+--------------------------
+
+To enable users to build reliable tools on top of the deSEC API, we
+maintain stable versions of the API for extended periods of time.
+
+Each API version will advance through the API version lifecycle,
+starting from `unstable` and proceeding to `stable`, `deprecated`,
+and, finally, to `historical`.
+
+Check out the `current status of the API versions`_ to make sure you
+are using the latest stable API whenever using our service in
+production.
+
+.. _current status of the API versions: https://github.com/desec-io/desec-stack/#api-versions-and-road-map
+
+**Unstable API versions** are currently under development and may
+change without prior notice, but we promise to keep an eye on users
+affected by incompatible changes.
+
+For all **stable API versions**, we guarantee that
+
+1. it will be maintained at least until the end of the given support
+   period,
+
+2. there will be no incompatible changes made to the interface, unless
+   security vulnerabilities make such changes inevitable,
+
+3. users will be warned before the end of the support period.
+
+**Deprecated API versions** are going to be disabled in the future.
+Users will be notified via email and are encouraged to migrate to the
+next stable version as soon as possible. For this purpose, a migration
+advisory will be provided. After the support period is over, deprecated
+API versions may be disabled without further warning and transition to
+historical state.
+
+**Historical API versions** are permanently disabled and cannot be used.
+

+ 7 - 0
test/e2e/schemas.js

@@ -1,5 +1,12 @@
 // For format specs, see https://json-schema.org/latest/json-schema-validation.html#rfc.section.7.3
 
+exports.rootNoLogin = {
+    properties: {
+        login: { type: "string" },
+        register: { type: "string" },
+    }
+};
+
 exports.user = {
     properties: {
         dyn: { type: "boolean" },

+ 34 - 3
test/e2e/spec/api_spec.js

@@ -4,7 +4,35 @@ var itPropagatesToTheApi = require("./../setup.js").itPropagatesToTheApi;
 var itShowsUpInPdnsAs = require("./../setup.js").itShowsUpInPdnsAs;
 var schemas = require("./../schemas.js");
 
-describe("API", function () {
+describe("API Versioning", function () {
+
+    before(function () {
+        chakram.setRequestDefaults({
+            headers: {
+                'Host': 'desec.' + process.env.DESECSTACK_DOMAIN,
+            },
+            followRedirect: false,
+            baseUrl: 'https://www/api',
+        })
+    });
+
+    [
+        'v1',
+        'v2',
+    ].forEach(function (version) {
+        it("maintains the requested version " + version, function() {
+            chakram.get('/' + version + '/').then(function (response) {
+                expect(response).to.have.schema(schemas.rootNoLogin);
+                let regex = new RegExp('http://[^/]+/api/' + version + '/auth/users/', 'g')
+                expect(response.body.login).to.match(regex);
+                return chakram.wait();
+            });
+        });
+    })
+
+});
+
+describe("API v1", function () {
     this.timeout(3000);
 
     before(function () {
@@ -18,8 +46,11 @@ describe("API", function () {
     });
 
     it("provides an index page", function () {
-        var response = chakram.get('/');
-        return expect(response).to.have.status(200);
+        chakram.get('/').then(function (response) {
+            expect(response).to.have.schema(schemas.rootNoLogin);
+            expect(response.body.login).to.match(/http:\/\/[^\/]+\/api\/v1\/auth\/users\//);
+            return chakram.wait();
+        });
     });
 
     describe("user registration", function () {