Repository: openwisp/openwisp-controller (language: python)

Issue:
[api] Create API endpoints for the models of `PKI` App
Implement a RESTfull API for `PKI` App of **openwisp-controller** module, the API should allow to permform CRUD (Create, Retrieve, Update, Destroy) operations on these objects:-
- Ca
- Cert

### Endpoints:-
- **Ca**: 
  - GET `api/v1/pki/ca/` (_Ca list's_)
  - GET `api/v1/pki/ca/{pk}/` (_Ca Detail_)
  - PUT/PATCH/DELETE `api/v1/pki/ca/{pk}/`
  - GET `api/v1/pki/ca/{pk}/crl` (_Download CRL_)

- **Cert**:
  - GET `api/v1/pki/cert/` (_Cert list's_)
  - GET `api/v1/pki/cert/{pk}/` (_Cert Detail_)
  - PUT/PATCH/DELETE `api/v1/pki/cert/{pk}/`

### All of the above API endpoints:-
- Will have to respect multi-tenancy which can be achieved by various serializer classes present `FilterSerializerByOrgMembership, FilterSerializerByOrgManaged`, and `FilterSerializerByOrgOwned` in the **openwisp-users** module.
- Must be paginated.
- Respect `django-permission` system and also it may be required to inherit the custom permission and authentication classes from the **openwisp-users** module to restrict API usage for security reasons. Some of the important custom permission classes are `IsOrganizationMember, IsOrganizationManager, IsOrganizationOwner,DjangoModelPermissions, IsAuthenticated`, and many more.
- Add authentication classes in every endpoint to allow only the authenticated users to get access to all the available endpoints. `BearerAuthentication` and `SessionAuthentication` can be added to achieve this.
- All the serializer classes should be validated by the class  `ValidatedModelSerializer` inheriting it from **openwisp-utils**.
- All the validation of the model should be preserved, also more validation  methods may be introduced.
- Use transaction method wherever necessary when the database operation is taking place.
- Shall be taken care of proper query optimization to not slow down the API and reduce the number of queries performed while fetching data from the database.
- Add proper tests for all the endpoints and make sure the coverage doesn't decrease.
- Handle errors gracefully and return HTTP response codes that indicate what kind of error occurred to eliminate confusion for API users.
- Follow Django REST Framework proper coding practices and RESTful principles for building all the API’s.
- Add proper documentation for all the endpoints both in the README and OpenAPI (DRF Yasg).


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 6a8e39ab4..691edd59e 100755
--- a/README.rst
+++ b/README.rst
@@ -952,7 +952,8 @@ Download template configuration
 
     GET /api/v1/controller/template/{id}/configuration/
 
-The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific template.
+The above endpoint triggers the download of a ``tar.gz`` file
+containing the generated configuration for that specific template.
 
 Change details of template
 ^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -1003,7 +1004,8 @@ Download VPN configuration
 
     GET /api/v1/controller/vpn/{id}/configuration/
 
-The above endpoint triggers the download of a ``tar.gz`` file containing the generated configuration for that specific VPN.
+The above endpoint triggers the download of a ``tar.gz`` file
+containing the generated configuration for that specific VPN.
 
 Change details of VPN
 ^^^^^^^^^^^^^^^^^^^^^
@@ -1026,6 +1028,143 @@ Delete VPN
 
     DELETE /api/v1/controller/vpn/{id}/
 
+List CA
+^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/ca/
+
+Create new CA
+^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/ca/
+
+Import existing CA
+^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/ca/
+
+**Note**: To import an existing CA, only ``name``, ``certificate``
+and ``private_key`` fields have to be filled in the ``HTML`` form or
+included in the ``JSON`` format.
+
+Get CA Detail
+^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/ca/{id}/
+
+Change details of CA
+^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PUT /api/v1/controller/ca/{id}/
+
+Patch details of CA
+^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PATCH /api/v1/controller/ca/{id}/
+
+Download CA(crl)
+^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/ca/{id}/crl/
+
+The above endpoint triggers the download of ``{id}.crl`` file containing 
+up to date CRL of that specific CA.
+
+Delete CA
+^^^^^^^^^
+
+.. code-block:: text
+
+    DELETE /api/v1/controller/ca/{id}/
+
+Renew CA
+^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/ca/{id}/renew/
+
+List Cert
+^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/cert/
+
+Create new Cert
+^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/cert/
+
+Import existing Cert
+^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/cert/
+
+**Note**: To import an existing Cert, only ``name``, ``ca``,
+``certificate`` and ``private_key`` fields have to be filled
+in the ``HTML`` form or included in the ``JSON`` format.
+
+Get Cert Detail
+^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    GET /api/v1/controller/cert/{id}/
+
+Change details of Cert
+^^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PUT /api/v1/controller/cert/{id}/
+
+Patch details of Cert
+^^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: text
+
+    PATCH /api/v1/controller/cert/{id}/
+
+Delete Cert
+^^^^^^^^^^^
+
+.. code-block:: text
+
+    DELETE /api/v1/controller/cert/{id}/
+
+Renew Cert
+^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/cert/{id}/renew/
+
+Revoke Cert
+^^^^^^^^^^^
+
+.. code-block:: text
+
+    POST /api/v1/controller/cert/{id}/revoke/
+
 Default Alerts / Notifications
 ------------------------------
 
diff --git a/openwisp_controller/pki/api/serializers.py b/openwisp_controller/pki/api/serializers.py
new file mode 100644
index 000000000..48b6c2288
--- /dev/null
+++ b/openwisp_controller/pki/api/serializers.py
@@ -0,0 +1,191 @@
+from django.utils.translation import ugettext_lazy as _
+from django_x509.base.models import (
+    default_ca_validity_end,
+    default_cert_validity_end,
+    default_validity_start,
+)
+from rest_framework import serializers
+from swapper import load_model
+
+from openwisp_users.api.mixins import FilterSerializerByOrgManaged
+from openwisp_utils.api.serializers import ValidatedModelSerializer
+
+Ca = load_model('django_x509', 'Ca')
+Cert = load_model('django_x509', 'Cert')
+
+
+class BaseSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer):
+    pass
+
+
+class BaseListSerializer(BaseSerializer):
+    extensions = serializers.JSONField(
+        initial=[],
+        help_text=_('additional x509 certificate extensions'),
+        required=False,
+    )
+
+    def get_import_data_hook(self, instance):
+        return get_import_data(instance)
+
+    def validate(self, data):
+        instance = self.instance or self.Meta.model(**data)
+        instance.full_clean()
+        if data.get('certificate') and data.get('private_key'):
+            data = self.get_import_data_hook(instance)
+        return data
+
+    def validate_validity_start(self, value):
+        if value is None:
+            value = default_validity_start()
+        return value
+
+    def default_validity_end_hook(self):
+        return default_ca_validity_end()
+
+    def validate_validity_end(self, value):
+        if value is None:
+            value = self.default_validity_end_hook()
+        return value
+
+
+class CaListSerializer(BaseListSerializer):
+    class Meta:
+        model = Ca
+        fields = [
+            'id',
+            'name',
+            'organization',
+            'notes',
+            'key_length',
+            'digest',
+            'validity_start',
+            'validity_end',
+            'country_code',
+            'state',
+            'city',
+            'organization_name',
+            'organizational_unit_name',
+            'email',
+            'common_name',
+            'extensions',
+            'serial_number',
+            'certificate',
+            'private_key',
+            'passphrase',
+            'created',
+            'modified',
+        ]
+        read_only_fields = ['created', 'modified']
+        extra_kwargs = {
+            'organization': {'required': True},
+            'key_length': {'initial': '2048'},
+            'digest': {'initial': 'sha256'},
+            'passphrase': {'write_only': True},
+            'validity_start': {'default': default_validity_start()},
+            'validity_end': {'default': default_ca_validity_end()},
+        }
+
+
+def get_ca_detail_fields(fields):
+    """
+    Returns the fields for the `CADetailSerializer`.
+    """
+    fields.remove('passphrase')
+    return fields
+
+
+class CaDetailSerializer(BaseSerializer):
+    extensions = serializers.JSONField(read_only=True)
+
+    class Meta:
+        model = Ca
+        fields = get_ca_detail_fields(CaListSerializer.Meta.fields[:])
+        read_only_fields = fields[4:]
+
+
+class CaRenewSerializer(CaDetailSerializer):
+    class Meta(CaDetailSerializer.Meta):
+        read_only_fields = CaDetailSerializer.Meta.fields
+
+
+def get_import_data(instance):
+    data = {
+        'name': instance.name,
+        'organization': instance.organization,
+        'key_length': instance.key_length,
+        'digest': instance.digest,
+        'validity_start': instance.validity_start,
+        'validity_end': instance.validity_end,
+        'country_code': instance.country_code,
+        'state': instance.state,
+        'city': instance.city,
+        'organization_name': instance.organization_name,
+        'organizational_unit_name': instance.organizational_unit_name,
+        'email': instance.email,
+        'common_name': instance.common_name,
+        'extensions': instance.extensions,
+        'serial_number': instance.serial_number,
+        'certificate': instance.certificate,
+        'private_key': instance.private_key,
+        'passphrase': instance.passphrase,
+    }
+    return data
+
+
+def get_cert_list_fields(fields):
+    """
+    Returns the fields for the `CertListSerializer`.
+    """
+    fields.insert(3, 'ca')
+    fields.insert(5, 'revoked')
+    fields.insert(6, 'revoked_at')
+    return fields
+
+
+class CertListSerializer(BaseListSerializer):
+    include_shared = True
+
+    class Meta:
+        model = Cert
+        fields = get_cert_list_fields(CaListSerializer.Meta.fields[:])
+        read_only_fields = ['created', 'modified']
+        extra_kwargs = {
+            'revoked': {'read_only': True},
+            'revoked_at': {'read_only': True},
+            'key_length': {'initial': '2048'},
+            'digest': {'initial': 'sha256'},
+            'passphrase': {'write_only': True},
+            'validity_start': {'default': default_validity_start()},
+            'validity_end': {'default': default_cert_validity_end()},
+        }
+
+    def get_import_data_hook(self, instance):
+        data = super().get_import_data_hook(instance)
+        data.update({'ca': instance.ca})
+        return data
+
+    def default_validity_end_hook(self):
+        return default_cert_validity_end()
+
+
+def get_cert_detail_fields(fields):
+    """
+    Returns the fields for the `CertDetailSerializer`.
+    """
+    fields.remove('passphrase')
+    return fields
+
+
+class CertDetailSerializer(BaseSerializer):
+    extensions = serializers.JSONField(read_only=True)
+
+    class Meta:
+        model = Cert
+        fields = get_cert_detail_fields(CertListSerializer.Meta.fields[:])
+        read_only_fields = ['ca'] + fields[5:]
+
+
+class CertRevokeRenewSerializer(CertDetailSerializer):
+    class Meta(CertDetailSerializer.Meta):
+        read_only_fields = CertDetailSerializer.Meta.fields
diff --git a/openwisp_controller/pki/api/urls.py b/openwisp_controller/pki/api/urls.py
new file mode 100644
index 000000000..08016e836
--- /dev/null
+++ b/openwisp_controller/pki/api/urls.py
@@ -0,0 +1,42 @@
+from django.conf import settings
+from django.urls import path
+
+from . import views as api_views
+
+app_name = 'openwisp_controller'
+
+
+def get_pki_api_urls(api_views):
+    """
+    returns:: all the API urls of the PKI app
+    """
+    if getattr(settings, 'OPENWISP_CONTROLLER_PKI_API', True):
+        return [
+            path('controller/ca/', api_views.ca_list, name='ca_list'),
+            path('controller/ca/<str:pk>/', api_views.ca_detail, name='ca_detail'),
+            path('controller/ca/<str:pk>/renew/', api_views.ca_renew, name='ca_renew'),
+            path(
+                'controller/ca/<str:pk>/crl',
+                api_views.crl_download,
+                name='crl_download',
+            ),
+            path('controller/cert/', api_views.cert_list, name='cert_list'),
+            path(
+                'controller/cert/<str:pk>/', api_views.cert_detail, name='cert_detail'
+            ),
+            path(
+                'controller/cert/<str:pk>/revoke/',
+                api_views.cert_revoke,
+                name='cert_revoke',
+            ),
+            path(
+                'controller/cert/<str:pk>/renew/',
+                api_views.cert_renew,
+                name='cert_renew',
+            ),
+        ]
+    else:
+        return []
+
+
+urlpatterns = get_pki_api_urls(api_views)
diff --git a/openwisp_controller/pki/api/views.py b/openwisp_controller/pki/api/views.py
new file mode 100644
index 000000000..4eda76b5a
--- /dev/null
+++ b/openwisp_controller/pki/api/views.py
@@ -0,0 +1,126 @@
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404
+from rest_framework import pagination, serializers
+from rest_framework.authentication import SessionAuthentication
+from rest_framework.generics import (
+    GenericAPIView,
+    ListCreateAPIView,
+    RetrieveAPIView,
+    RetrieveUpdateDestroyAPIView,
+)
+from rest_framework.permissions import DjangoModelPermissions, IsAuthenticated
+from rest_framework.response import Response
+from swapper import load_model
+
+from openwisp_users.api.authentication import BearerAuthentication
+from openwisp_users.api.mixins import FilterByOrganizationManaged
+
+from .serializers import (
+    CaDetailSerializer,
+    CaListSerializer,
+    CaRenewSerializer,
+    CertDetailSerializer,
+    CertListSerializer,
+    CertRevokeRenewSerializer,
+)
+
+Ca = load_model('django_x509', 'Ca')
+Cert = load_model('django_x509', 'Cert')
+
+
+class ListViewPagination(pagination.PageNumberPagination):
+    page_size = 10
+    page_size_query_param = 'page_size'
+    max_page_size = 100
+
+
+class ProtectedAPIMixin(FilterByOrganizationManaged):
+    authentication_classes = [BearerAuthentication, SessionAuthentication]
+    permission_classes = [
+        IsAuthenticated,
+        DjangoModelPermissions,
+    ]
+
+
+class CaListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+    serializer_class = CaListSerializer
+    queryset = Ca.objects.order_by('-created')
+    pagination_class = ListViewPagination
+
+
+class CaDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+    serializer_class = CaDetailSerializer
+    queryset = Ca.objects.all()
+
+
+class CaRenewView(ProtectedAPIMixin, GenericAPIView):
+    serializer_class = serializers.Serializer
+    queryset = Ca.objects.all()
+
+    def post(self, request, pk):
+        """
+        Renews the CA.
+        """
+        instance = self.get_object()
+        instance.renew()
+        serializer = CaRenewSerializer(instance)
+        return Response(serializer.data, status=200)
+
+
+class CrlDownloadView(ProtectedAPIMixin, RetrieveAPIView):
+    serializer_class = CaDetailSerializer
+    queryset = Ca.objects.none()
+
+    def retrieve(self, request, *args, **kwargs):
+        instance = get_object_or_404(Ca, pk=kwargs['pk'])
+        return HttpResponse(
+            instance.crl, status=200, content_type='application/x-pem-file'
+        )
+
+
+class CertListCreateView(ProtectedAPIMixin, ListCreateAPIView):
+    serializer_class = CertListSerializer
+    queryset = Cert.objects.order_by('-created')
+    pagination_class = ListViewPagination
+
+
+class CertDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
+    serializer_class = CertDetailSerializer
+    queryset = Cert.objects.select_related('ca')
+
+
+class CertRevokeRenewBaseView(ProtectedAPIMixin, GenericAPIView):
+    serializer_class = serializers.Serializer
+    queryset = Cert.objects.select_related('ca')
+
+
+class CertRevokeView(CertRevokeRenewBaseView):
+    def post(self, request, pk):
+        """
+        Revokes the Certificate.
+        """
+        instance = self.get_object()
+        instance.revoke()
+        serializer = CertRevokeRenewSerializer(instance)
+        return Response(serializer.data, status=200)
+
+
+class CertRenewView(CertRevokeRenewBaseView):
+    def post(self, request, pk):
+        """
+        Renews the Certificate.
+        """
+        instance = self.get_object()
+        instance.renew()
+        serializer = CertRevokeRenewSerializer(instance)
+        return Response(serializer.data, status=200)
+
+
+ca_list = CaListCreateView.as_view()
+ca_detail = CaDetailView.as_view()
+ca_renew = CaRenewView.as_view()
+cert_list = CertListCreateView.as_view()
+cert_detail = CertDetailView.as_view()
+crl_download = CrlDownloadView.as_view()
+cert_revoke = CertRevokeView.as_view()
+cert_renew = CertRenewView.as_view()


Test patch:
diff --git a/openwisp_controller/pki/tests/test_api.py b/openwisp_controller/pki/tests/test_api.py
new file mode 100644
index 000000000..d9c31724a
--- /dev/null
+++ b/openwisp_controller/pki/tests/test_api.py
@@ -0,0 +1,293 @@
+from django.test import TestCase
+from django.urls import reverse
+from swapper import load_model
+
+from openwisp_controller.tests.utils import TestAdminMixin
+from openwisp_users.tests.utils import TestOrganizationMixin
+from openwisp_utils.tests import AssertNumQueriesSubTestMixin
+
+from .utils import TestPkiMixin
+
+Ca = load_model('django_x509', 'Ca')
+Cert = load_model('django_x509', 'Cert')
+
+
+class TestPkiApi(
+    AssertNumQueriesSubTestMixin,
+    TestAdminMixin,
+    TestPkiMixin,
+    TestOrganizationMixin,
+    TestCase,
+):
+    def setUp(self):
+        super().setUp()
+        self._login()
+
+    _get_ca_data = {
+        'name': 'Test CA',
+        'organization': None,
+        'key_length': '2048',
+        'digest': 'sha256',
+    }
+
+    _get_cert_data = {
+        'name': 'Test Cert',
+        'organization': None,
+        'ca': None,
+        'key_length': '2048',
+        'digest': 'sha256',
+        'serial_number': "",
+    }
+
+    def test_ca_post_api(self):
+        self.assertEqual(Ca.objects.count(), 0)
+        path = reverse('pki_api:ca_list')
+        data = self._get_ca_data.copy()
+        with self.assertNumQueries(4):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Ca.objects.count(), 1)
+
+    def test_ca_post_with_extensions_field(self):
+        self.assertEqual(Ca.objects.count(), 0)
+        path = reverse('pki_api:ca_list')
+        data = self._get_ca_data.copy()
+        data['extensions'] = []
+        with self.assertNumQueries(4):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(r.data['extensions'], [])
+        self.assertEqual(Ca.objects.count(), 1)
+
+    def test_ca_import_post_api(self):
+        ca1 = self._create_ca()
+        path = reverse('pki_api:ca_list')
+        data = {
+            'name': 'import-ca-test',
+            'organization': self._get_org().pk,
+            'certificate': ca1.certificate,
+            'private_key': ca1.private_key,
+        }
+        with self.assertNumQueries(5):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Ca.objects.count(), 2)
+        self.assertEqual(r.data['serial_number'], str(ca1.serial_number))
+        self.assertEqual(r.data['state'], ca1.state)
+        self.assertEqual(r.data['city'], ca1.city)
+        self.assertEqual(r.data['email'], ca1.email)
+
+    def test_ca_post_with_date_none_api(self):
+        self.assertEqual(Ca.objects.count(), 0)
+        path = reverse('pki_api:ca_list')
+        data = {
+            'name': 'test-ca',
+            'organization': None,
+            'validity_start': None,
+            'validity_end': None,
+        }
+        with self.assertNumQueries(4):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Ca.objects.count(), 1)
+
+    def test_ca_list_api(self):
+        self._create_ca()
+        path = reverse('pki_api:ca_list')
+        with self.assertNumQueries(4):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertNotIn('passphrase', r.content.decode('utf8'))
+
+    def test_ca_detail_api(self):
+        ca1 = self._create_ca(name='ca1', organization=self._get_org())
+        path = reverse('pki_api:ca_detail', args=[ca1.pk])
+        with self.assertNumQueries(3):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['id'], ca1.pk)
+        self.assertEqual(r.data['extensions'], [])
+
+    def test_ca_put_api(self):
+        ca1 = self._create_ca(name='ca1', organization=self._get_org())
+        path = reverse('pki_api:ca_detail', args=[ca1.pk])
+        org2 = self._create_org()
+        data = {'name': 'change-ca1', 'organization': org2.pk, 'notes': 'change-notes'}
+        with self.assertNumQueries(6):
+            r = self.client.put(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'change-ca1')
+        self.assertEqual(r.data['organization'], org2.pk)
+        self.assertEqual(r.data['notes'], 'change-notes')
+
+    def test_ca_patch_api(self):
+        ca1 = self._create_ca(name='ca1', organization=self._get_org())
+        path = reverse('pki_api:ca_detail', args=[ca1.pk])
+        data = {
+            'name': 'change-ca1',
+        }
+        with self.assertNumQueries(5):
+            r = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'change-ca1')
+
+    def test_crl_download_api(self):
+        ca1 = self._create_ca(name='ca1', organization=self._get_org())
+        path = reverse('pki_api:crl_download', args=[ca1.pk])
+        with self.assertNumQueries(4):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+
+    def test_ca_delete_api(self):
+        ca1 = self._create_ca(name='ca1', organization=self._get_org())
+        path = reverse('pki_api:ca_detail', args=[ca1.pk])
+        with self.assertNumQueries(8):
+            r = self.client.delete(path)
+        self.assertEqual(r.status_code, 204)
+        self.assertEqual(Ca.objects.count(), 0)
+
+    def test_ca_post_renew_api(self):
+        ca1 = self._create_ca(name='ca1', organization=self._get_org())
+        old_serial_num = ca1.serial_number
+        path = reverse('pki_api:ca_renew', args=[ca1.pk])
+        with self.assertNumQueries(5):
+            r = self.client.post(path)
+        ca1.refresh_from_db()
+        self.assertEqual(r.status_code, 200)
+        self.assertNotEqual(ca1.serial_number, old_serial_num)
+        self.assertNotEqual(r.data['serial_number'], old_serial_num)
+
+    def test_cert_post_api(self):
+        path = reverse('pki_api:cert_list')
+        data = self._get_cert_data.copy()
+        data['ca'] = self._create_ca().pk
+        with self.assertNumQueries(9):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Cert.objects.count(), 1)
+
+    def test_import_cert_post_api(self):
+        path = reverse('pki_api:cert_list')
+        ca1 = self._create_ca()
+        data = {
+            'name': 'import-test-ca',
+            'organization': self._get_org().pk,
+            'ca': ca1.id,
+            'serial_number': '',
+            'certificate': ca1.certificate,
+            'private_key': ca1.private_key,
+        }
+        with self.assertNumQueries(9):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Cert.objects.count(), 1)
+        self.assertEqual(r.data['serial_number'], str(ca1.serial_number))
+        self.assertEqual(r.data['state'], ca1.state)
+        self.assertEqual(r.data['city'], ca1.city)
+        self.assertEqual(r.data['email'], ca1.email)
+
+    def test_cert_post_with_extensions_field(self):
+        path = reverse('pki_api:cert_list')
+        data = self._get_cert_data.copy()
+        data['ca'] = self._create_ca().pk
+        data['extensions'] = []
+        with self.assertNumQueries(9):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Cert.objects.count(), 1)
+        self.assertEqual(r.data['extensions'], [])
+
+    def test_cert_post_with_date_none(self):
+        path = reverse('pki_api:cert_list')
+        data = {
+            'name': 'test-cert',
+            'ca': self._create_ca().pk,
+            'serial_number': "",
+            'validity_start': None,
+            'validity_end': None,
+        }
+        with self.assertNumQueries(9):
+            r = self.client.post(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 201)
+        self.assertEqual(Cert.objects.count(), 1)
+
+    def test_cert_list_api(self):
+        self._create_cert(name='cert1')
+        path = reverse('pki_api:cert_list')
+        with self.assertNumQueries(4):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(Cert.objects.count(), 1)
+        self.assertNotIn('passphrase', r.content.decode('utf8'))
+
+    def test_cert_detail_api(self):
+        cert1 = self._create_cert(name='cert1')
+        path = reverse('pki_api:cert_detail', args=[cert1.pk])
+        with self.assertNumQueries(3):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['id'], cert1.pk)
+        self.assertEqual(r.data['extensions'], [])
+
+    def test_cert_put_api(self):
+        cert1 = self._create_cert(name='cert1')
+        org2 = self._create_org()
+        path = reverse('pki_api:cert_detail', args=[cert1.pk])
+        data = {
+            'name': 'cert1-change',
+            'organization': org2.pk,
+            'notes': 'new-notes',
+        }
+        with self.assertNumQueries(8):
+            r = self.client.put(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'cert1-change')
+        self.assertEqual(r.data['organization'], org2.pk)
+        self.assertEqual(r.data['notes'], 'new-notes')
+
+    def test_cert_patch_api(self):
+        cert1 = self._create_cert(name='cert1')
+        path = reverse('pki_api:cert_detail', args=[cert1.pk])
+        data = {'name': 'cert1-change'}
+        with self.assertNumQueries(7):
+            r = self.client.patch(path, data, content_type='application/json')
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['name'], 'cert1-change')
+
+    def test_cert_delete_api(self):
+        cert1 = self._create_cert(name='cert1')
+        path = reverse('pki_api:cert_detail', args=[cert1.pk])
+        with self.assertNumQueries(8):
+            r = self.client.delete(path)
+        self.assertEqual(r.status_code, 204)
+        self.assertEqual(Cert.objects.count(), 0)
+
+    def test_ca_in_cert_detail_fields(self):
+        cert1 = self._create_cert(name='cert1')
+        path = reverse('pki_api:cert_detail', args=[cert1.pk])
+        with self.assertNumQueries(3):
+            r = self.client.get(path)
+        self.assertEqual(r.status_code, 200)
+        self.assertEqual(r.data['ca'], cert1.ca.id)
+
+    def test_post_cert_renew_api(self):
+        cert1 = self._create_cert(name='cert1')
+        old_serial_num = cert1.serial_number
+        path = reverse('pki_api:cert_renew', args=[cert1.pk])
+        with self.assertNumQueries(5):
+            r = self.client.post(path)
+        self.assertEqual(r.status_code, 200)
+        cert1.refresh_from_db()
+        self.assertNotEqual(cert1.serial_number, old_serial_num)
+        self.assertNotEqual(r.data['serial_number'], old_serial_num)
+
+    def test_post_cert_revoke_api(self):
+        cert1 = self._create_cert(name='cert1')
+        self.assertFalse(cert1.revoked)
+        path = reverse('pki_api:cert_revoke', args=[cert1.pk])
+        with self.assertNumQueries(5):
+            r = self.client.post(path)
+        cert1.refresh_from_db()
+        self.assertEqual(r.status_code, 200)
+        self.assertTrue(cert1.revoked)
+        self.assertTrue(r.data['revoked'])
diff --git a/openwisp_controller/pki/tests/test_models.py b/openwisp_controller/pki/tests/test_models.py
index c5de7d0c8..5582acb2b 100644
--- a/openwisp_controller/pki/tests/test_models.py
+++ b/openwisp_controller/pki/tests/test_models.py
@@ -4,6 +4,7 @@
 from OpenSSL import crypto
 from swapper import load_model
 
+from openwisp_controller.tests.utils import TestAdminMixin
 from openwisp_users.tests.utils import TestOrganizationMixin
 
 from .utils import TestPkiMixin
@@ -12,7 +13,7 @@
 Cert = load_model('django_x509', 'Cert')
 
 
-class TestModels(TestPkiMixin, TestOrganizationMixin, TestCase):
+class TestModels(TestAdminMixin, TestPkiMixin, TestOrganizationMixin, TestCase):
     def test_ca_creation_with_org(self):
         org = self._get_org()
         ca = self._create_ca(organization=org)
@@ -46,6 +47,7 @@ def test_cert_validate_org_relation_no_rel(self):
             cert.full_clean()
 
     def test_crl_view(self):
+        self._login()
         ca = self._create_ca()
         response = self.client.get(reverse('admin:crl', args=[ca.pk]))
         self.assertEqual(response.status_code, 200)
diff --git a/tests/openwisp2/urls.py b/tests/openwisp2/urls.py
index 8895f674f..554473313 100644
--- a/tests/openwisp2/urls.py
+++ b/tests/openwisp2/urls.py
@@ -63,6 +63,7 @@
     path('accounts/', include('openwisp_users.accounts.urls')),
     path('api/v1/', include('openwisp_utils.api.urls')),
     path('api/v1/', include(('openwisp_users.api.urls', 'users'), namespace='users')),
+    path('api/v1/', include(('openwisp_controller.pki.api.urls'), namespace='pki_api')),
 ]
 
 urlpatterns += staticfiles_urlpatterns()