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

Issue:
[tests] Add captive portal mock views
I wrote some captive portal login/logout views which are meant to be used from https://github.com/openwisp/openwisp-wifi-login-pages, but they're missing tests and docs about how to use them.
[tests] Add captive portal mock views
I wrote some captive portal login/logout views which are meant to be used from https://github.com/openwisp/openwisp-wifi-login-pages, but they're missing tests and docs about how to use them.


Discussion / hints:

Fix patch:
diff --git a/docs/source/developer/captive_portal_mock.rst b/docs/source/developer/captive_portal_mock.rst
new file mode 100644
index 00000000..6bf8da38
--- /dev/null
+++ b/docs/source/developer/captive_portal_mock.rst
@@ -0,0 +1,37 @@
+=========================
+Captive portal mock views
+=========================
+
+The development environment of openwisp-radius provides two URLs that mock
+the behavior of a captive portal, these URLs can be used when testing
+frontend applications like
+`openwisp-wifi-login-pages <https://github.com/openwisp/openwisp-wifi-login-pages>`_
+during development.
+
+.. note::
+   These views are meant to be used just for development and testing.
+
+Captive Portal Login Mock View
+------------------------------
+
+- **URL**: ``http://localhost:8000/captive-portal-mock/login/``.
+- **POST fields**: ``auth_pass`` or ``password``.
+
+This view looks for ``auth_pass`` or ``password`` in the POST request data,
+and if it finds anything will try to look for any ``RadiusToken`` instance
+having its key equal to this value, and if it does find one, it will create a
+radius session (an entry in the ``radacct`` table) related to the user
+to which the radius token belongs, provided there's no other open session
+for the same user.
+
+Captive Portal Logout Mock View
+-------------------------------
+
+- **URL**: ``http://localhost:8000/captive-portal-mock/logout/``.
+- **POST fields**: ``logout_id``.
+
+This view looks for an entry in the ``radacct`` table with ``session_id``
+equals to what is passed in the ``logout_id`` POST field and if it finds
+one, it flags the session as terminated with ``User-Request``
+termination cause and populates the ``stop_time`` column with the current
+UTC time.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 56cac205..6015992e 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -60,6 +60,7 @@ openwisp-radius
    /user/social_login
    /user/api
    /developer/how_to_extend
+   /developer/captive_portal_mock.rst
    /general/support
    /developer/contributing
    /general/goals


Test patch:
diff --git a/tests/openwisp2/integrations/tests.py b/tests/openwisp2/integrations/tests.py
index a2720a95..199c55d4 100644
--- a/tests/openwisp2/integrations/tests.py
+++ b/tests/openwisp2/integrations/tests.py
@@ -1,8 +1,14 @@
+from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.urls import reverse
 
+from openwisp_radius.utils import load_model
 from openwisp_users.tests.utils import TestOrganizationMixin
 
+RadiusAccounting = load_model('RadiusAccounting')
+RadiusToken = load_model('RadiusToken')
+User = get_user_model()
+
 
 class TestIntegrations(TestOrganizationMixin, TestCase):
     def test_swagger_api_docs(self):
@@ -10,3 +16,49 @@ def test_swagger_api_docs(self):
         self.client.force_login(admin)
         response = self.client.get(reverse('schema-swagger-ui'), {'format': 'openapi'})
         self.assertEqual(response.status_code, 200)
+
+    def test_captive_portal_login_mock(self):
+        url = reverse('captive_portal_login_mock')
+        radius_token, ra = (None, None)
+
+        with self.subTest('no action to perform, ensure no error'):
+            response = self.client.post(url)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(RadiusAccounting.objects.count(), 0)
+            self.assertEqual(RadiusToken.objects.count(), 0)
+
+        with self.subTest('radius token matches, session is created'):
+            user = self._get_user()
+            org = self._get_org()
+            radius_token = RadiusToken.objects.create(user=user, organization=org)
+            response = self.client.post(url, {'auth_pass': radius_token.key})
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(RadiusToken.objects.count(), 1)
+            self.assertEqual(RadiusAccounting.objects.count(), 1)
+            ra = RadiusAccounting.objects.first()
+            self.assertIsNone(ra.stop_time)
+            self.assertEqual(ra.username, user.username)
+
+        return radius_token, ra
+
+    def test_captive_portal_logout_mock(self):
+        url = reverse('captive_portal_logout_mock')
+
+        with self.subTest('no action to perform, ensure no error'):
+            response = self.client.post(url)
+            self.assertEqual(response.status_code, 200)
+            response = self.client.post(url, {'logout_id': '123'})
+            self.assertEqual(response.status_code, 200)
+
+        radius_token, ra = self.test_captive_portal_login_mock()
+
+        with self.subTest('logout_id matches, RadiusAccounting closed'):
+            assert radius_token
+            assert ra
+            response = self.client.post(url, {'logout_id': ra.session_id})
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(RadiusToken.objects.count(), 1)
+            self.assertEqual(RadiusAccounting.objects.count(), 1)
+            ra.refresh_from_db()
+            self.assertIsNotNone(ra.stop_time)
+            self.assertEqual(ra.terminate_cause, 'User-Request')
diff --git a/tests/openwisp2/urls.py b/tests/openwisp2/urls.py
index f5346554..4440f5fc 100644
--- a/tests/openwisp2/urls.py
+++ b/tests/openwisp2/urls.py
@@ -8,6 +8,8 @@
 
 from openwisp_radius.urls import get_urls
 
+from . import views
+
 if os.environ.get('SAMPLE_APP', False):
     # If you are extending the API views or social views,
     # please import them, otherwise pass `None` in place
@@ -27,6 +29,16 @@
     path('api/v1/', include('openwisp_users.api.urls')),
     path('accounts/', include('openwisp_users.accounts.urls')),
     radius_urls,
+    path(
+        'captive-portal-mock/login/',
+        views.captive_portal_login,
+        name='captive_portal_login_mock',
+    ),
+    path(
+        'captive-portal-mock/logout/',
+        views.captive_portal_logout,
+        name='captive_portal_logout_mock',
+    ),
 ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
 
 urlpatterns += staticfiles_urlpatterns()
diff --git a/tests/openwisp2/views.py b/tests/openwisp2/views.py
new file mode 100644
index 00000000..7eabff18
--- /dev/null
+++ b/tests/openwisp2/views.py
@@ -0,0 +1,65 @@
+import logging
+import uuid
+from datetime import datetime
+
+from django.contrib.auth import get_user_model
+from django.http import HttpResponse
+from django.views.decorators.clickjacking import xframe_options_exempt
+from django.views.decorators.csrf import csrf_exempt
+
+from openwisp_radius.utils import load_model
+
+RadiusAccounting = load_model('RadiusAccounting')
+RadiusToken = load_model('RadiusToken')
+User = get_user_model()
+logger = logging.getLogger('django.server')
+
+
+@csrf_exempt
+@xframe_options_exempt
+def captive_portal_login(request):
+    logger.info(f'Captive portal login mock view called with: {request.POST}')
+    password = request.POST.get('auth_pass') or request.POST.get('password')
+    radius_token = (
+        RadiusToken.objects.filter(key=password).select_related('user').first()
+    )
+    if (
+        radius_token
+        and not RadiusAccounting.objects.filter(
+            username=radius_token.user.username, stop_time=None
+        ).exists()
+    ):
+        id_ = uuid.uuid4().hex
+        ra = RadiusAccounting(
+            username=radius_token.user.username,
+            organization_id=radius_token.organization_id,
+            unique_id=id_,
+            session_id=id_,
+            nas_ip_address='127.0.0.1',
+        )
+        ra.full_clean()
+        ra.save()
+        logger.info(
+            f'RadiusAccounting session {ra.session_id} created for {ra.username}'
+        )
+    return HttpResponse('logged in')
+
+
+@csrf_exempt
+@xframe_options_exempt
+def captive_portal_logout(request):
+    logger.info(f'Captive portal logout mock view called with: {request.POST}')
+    session_id = request.POST.get('logout_id')
+    if session_id:
+        try:
+            ra = RadiusAccounting.objects.get(session_id=session_id)
+        except RadiusAccounting.DoesNotExist:
+            ra = None
+        if ra:
+            ra.stop_time = datetime.utcnow()
+            ra.terminate_cause = 'User-Request'
+            ra.save()
+            logger.info(
+                f'RadiusAccounting session {ra.session_id} terminated by {ra.username}'
+            )
+    return HttpResponse('logged out in')