Skip to content

Commit

Permalink
Tethys Async Websocket Consumer with Permission Checks (#1012)
Browse files Browse the repository at this point in the history
* initial consumerbase class

* new TethysAsyncWebsocketConsumer class with stubbed methods for implementation
also stubbed out other custom methods just in case they are needed in the future

* added tests and stubbed out methods for future development

* updated docs to use new method

* linted code and ran black formatter

* initial restructure of authenticated websocket consumer
not uses the decorator for permissions
added additional args for login_required and permissions_use_or

* cleaned up code and moved functions

* cleaned up manual copying of class methods

* more small code changes

* updated docs

* removed old permissions property for the docs example

* black formatted and linted code

* updated tests

* added another permission example to the consumer decorator

* updated examples in the controller decorator docstring

* mocked channels db for docs

* cleaned and simplified update_decorated_websocket_consumer_class code

* removed unused import
  • Loading branch information
ckrew committed Mar 12, 2024
1 parent 9f5b60c commit d65dd68
Show file tree
Hide file tree
Showing 127 changed files with 754 additions and 28 deletions.
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"bokeh.server.django.consumers",
"bokeh.util.compiler",
"channels",
"channels.db",
"channels.consumer",
"conda",
"conda.cli",
Expand Down
12 changes: 6 additions & 6 deletions docs/tutorials/websockets.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ a. Create a new file called ``consumers.py`` and add the following code:
@consumer(name='dam_notification', url='dams/notifications')
class NotificationsConsumer(AsyncWebsocketConsumer):
async def connect(self):
await self.accept()
async def authorized_connect(self):
print("-----------WebSocket Connected-----------")
async def disconnect(self, close_code):
async def authorized_disconnect(self, close_code):
pass
.. note::
Expand Down Expand Up @@ -94,12 +94,12 @@ a. Update the ``consumer class`` to look like this.
@consumer(name='dam_notification', url='dams/notifications')
class NotificationsConsumer(AsyncWebsocketConsumer):
async def connect(self):
await self.accept()
async def authorized_connect(self):
await self.channel_layer.group_add("notifications", self.channel_name)
print(f"Added {self.channel_name} channel to notifications")
async def disconnect(self, close_code):
async def authorized_disconnect(self, close_code):
await self.channel_layer.group_discard("notifications", self.channel_name)
print(f"Removed {self.channel_name} channel from notifications")
Expand Down
1 change: 1 addition & 0 deletions tests/unit_tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Copyright: (c) Aquaveo 2018
********************************************************************************
"""

import uuid
import factory
from unittest import mock
Expand Down
256 changes: 256 additions & 0 deletions tests/unit_tests/test_tethys_apps/test_base/test_mixins.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import unittest
from unittest import mock
import tethys_apps.base.mixins as tethys_mixins
from ... import UserFactory


class TestTethysBaseMixin(unittest.TestCase):
Expand All @@ -13,3 +15,257 @@ def test_TethysBaseMixin(self):
result = tethys_mixins.TethysBaseMixin()
result.root_url = "test-url"
self.assertEqual("test_url", result.url_namespace)


class TestTethysAsyncWebsocketConsumer(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.consumer = tethys_mixins.TethysAsyncWebsocketConsumerMixin()
self.consumer.accept = mock.AsyncMock()
self.consumer.permissions = ["test_permission"]
self.consumer.scope = {"user": UserFactory(), "path": "path/to/app"}

def tearDown(self):
pass

def test_perms_list(self):
self.assertTrue(self.consumer.perms == ["test_permission"])

def test_perms_none(self):
self.consumer.permissions = None
self.assertTrue(self.consumer.perms == [])

def test_perms_str(self):
self.consumer.permissions = "test_permission,test_permission1"
self.assertTrue(self.consumer.perms == ["test_permission", "test_permission1"])

def test_perms_exception(self):
self.consumer.permissions = {"test": "test_permsision"}
with self.assertRaises(TypeError) as context:
self.consumer.perms

self.assertTrue(
context.exception.args[0]
== "permissions must be a list, tuple, or comma separated string"
)

async def test_authorized_login_required_success(self):
self.consumer.permissions = []
self.consumer.login_required = True
self.assertTrue(await self.consumer.authorized)

async def test_authorized_login_required_failure(self):
self.consumer.permissions = []
self.consumer.login_required = True
self.consumer.scope = {
"user": mock.MagicMock(is_authenticated=False),
"path": "path/to/app",
}
self.assertFalse(await self.consumer.authorized)

@mock.patch("tethys_apps.base.mixins.scoped_user_has_permission")
async def test_authorized_permissions_and(self, mock_suhp):
self.consumer.permissions = ["test_permission", "test_permission1"]
mock_suhp.side_effect = [True, True]
self.assertTrue(await self.consumer.authorized)

@mock.patch("tethys_apps.base.mixins.scoped_user_has_permission")
async def test_authorized_inadequate_permissions_and(self, mock_suhp):
self.consumer.permissions = ["test_permission", "test_permission1"]
mock_suhp.side_effect = [True, False]
self.assertFalse(await self.consumer.authorized)

@mock.patch("tethys_apps.base.mixins.scoped_user_has_permission")
async def test_authorized_permissions_or(self, mock_suhp):
self.consumer.permissions = ["test_permission", "test_permission1"]
self.consumer.permissions_use_or = True
mock_suhp.side_effect = [True, False]
self.assertTrue(await self.consumer.authorized)

@mock.patch("tethys_apps.base.mixins.scoped_user_has_permission")
async def test_authorized_inadequate_permissions_or(self, mock_suhp):
self.consumer.permissions = ["test_permission", "test_permission1"]
self.consumer.permissions_use_or = True
mock_suhp.side_effect = [False, False]
self.assertFalse(await self.consumer.authorized)

async def test_authorized_connect(self):
await self.consumer.authorized_connect()

async def test_unauthorized_connect(self):
await self.consumer.unauthorized_connect()

async def test_authorized_disconnect(self):
event = {}
await self.consumer.authorized_disconnect(event)

async def test_unauthorized_disconnect(self):
event = {}
await self.consumer.unauthorized_disconnect(event)

@mock.patch(
"tethys_apps.base.mixins.TethysAsyncWebsocketConsumerMixin.authorized_connect"
)
async def test_connect(self, mock_authorized_connect):
self.consumer._authorized = True
await self.consumer.connect()
self.consumer.accept.assert_called_once()
mock_authorized_connect.assert_called_once()

@mock.patch(
"tethys_apps.base.mixins.TethysAsyncWebsocketConsumerMixin.unauthorized_connect"
)
async def test_connect_not_authorized(self, mock_unauthorized_connect):
self.consumer._authorized = False
await self.consumer.connect()
self.consumer.accept.assert_not_called()
mock_unauthorized_connect.assert_called_once()

@mock.patch(
"tethys_apps.base.mixins.TethysAsyncWebsocketConsumerMixin.authorized_disconnect"
)
async def test_disconnect(self, mock_authorized_disconnect):
self.consumer._authorized = True
event = "event"
await self.consumer.disconnect(event)
mock_authorized_disconnect.assert_called_with(event)

@mock.patch(
"tethys_apps.base.mixins.TethysAsyncWebsocketConsumerMixin.unauthorized_disconnect"
)
async def test_disconnect_not_authorized(self, mock_unauthorized_disconnect):
self.consumer._authorized = False
event = "event"
await self.consumer.disconnect(event)
mock_unauthorized_disconnect.assert_called_once()


class TestTethysWebsocketConsumer(unittest.TestCase):
def setUp(self):
self.consumer = tethys_mixins.TethysWebsocketConsumerMixin()
self.consumer.accept = mock.MagicMock()
self.consumer.permissions = ["test_permission"]
self.consumer.scope = {"user": UserFactory(), "path": "path/to/app"}

def tearDown(self):
pass

def test_perms_list(self):
self.assertTrue(self.consumer.perms == ["test_permission"])

def test_perms_none(self):
self.consumer.permissions = None
self.assertTrue(self.consumer.perms == [])

def test_perms_str(self):
self.consumer.permissions = "test_permission,test_permission1"
self.assertTrue(self.consumer.perms == ["test_permission", "test_permission1"])

def test_perms_exception(self):
self.consumer.permissions = {"test": "test_permsision"}
with self.assertRaises(TypeError) as context:
self.consumer.perms

self.assertTrue(
context.exception.args[0]
== "permissions must be a list, tuple, or comma separated string"
)

def test_authorized_login_required_success(self):
self.consumer.permissions = []
self.consumer.login_required = True
self.assertTrue(self.consumer.authorized)

def test_authorized_login_required_failure(self):
self.consumer.permissions = []
self.consumer.login_required = True
self.consumer.scope = {
"user": mock.MagicMock(is_authenticated=False),
"path": "path/to/app",
}
self.assertFalse(self.consumer.authorized)

def test_authorized_permissions_and(self):
self.consumer.permissions = ["test_permission"]
with mock.patch(
"tethys_apps.base.mixins.scoped_user_has_permission", user_has_perms
):
self.assertTrue(self.consumer.authorized)

def test_authorized_inadequate_permissions_and(self):
self.consumer.permissions = ["test_permission", "test_permission1"]
with mock.patch(
"tethys_apps.base.mixins.scoped_user_has_permission", user_has_perms
):
self.assertFalse(self.consumer.authorized)

def test_authorized_permissions_or(self):
self.consumer.permissions = ["test_permission", "test_permission1"]
self.consumer.permissions_use_or = True
with mock.patch(
"tethys_apps.base.mixins.scoped_user_has_permission", user_has_perms
):
self.assertTrue(self.consumer.authorized)

def test_authorized_inadequate_permissions_or(self):
self.consumer.permissions = ["test_permission1"]
self.consumer.permissions_use_or = True
with mock.patch(
"tethys_apps.base.mixins.scoped_user_has_permission", user_has_perms
):
self.assertFalse(self.consumer.authorized)

def test_authorized_connect(self):
self.consumer.authorized_connect()

def test_unauthorized_connect(self):
self.consumer.unauthorized_connect()

def test_authorized_disconnect(self):
event = {}
self.consumer.authorized_disconnect(event)

def test_unauthorized_disconnect(self):
event = {}
self.consumer.unauthorized_disconnect(event)

@mock.patch(
"tethys_apps.base.mixins.TethysWebsocketConsumerMixin.authorized_connect"
)
def test_connect(self, mock_authorized_connect):
self.consumer._authorized = True
self.consumer.connect()
self.consumer.accept.assert_called_once()
mock_authorized_connect.assert_called_once()

@mock.patch(
"tethys_apps.base.mixins.TethysWebsocketConsumerMixin.unauthorized_connect"
)
def test_connect_not_authorized(self, mock_unauthorized_connect):
self.consumer._authorized = False
self.consumer.connect()
self.consumer.accept.assert_not_called()
mock_unauthorized_connect.assert_called_once()

@mock.patch(
"tethys_apps.base.mixins.TethysWebsocketConsumerMixin.authorized_disconnect"
)
def test_disconnect(self, mock_authorized_disconnect):
self.consumer._authorized = True
event = "event"
self.consumer.disconnect(event)
mock_authorized_disconnect.assert_called_with(event)

@mock.patch(
"tethys_apps.base.mixins.TethysWebsocketConsumerMixin.unauthorized_disconnect"
)
def test_disconnect_not_authorized(self, mock_unauthorized_disconnect):
self.consumer._authorized = False
event = "event"
self.consumer.disconnect(event)
mock_unauthorized_disconnect.assert_called_once()


def user_has_perms(_, perm):
if perm == "test_permission":
return True
return False
32 changes: 32 additions & 0 deletions tests/unit_tests/test_tethys_apps/test_base/test_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,35 @@ def test_has_permission_no(self, mock_app):
mock_app.return_value = mock.MagicMock(package="test_package")
result = tethys_permission.has_permission(request=request, perm="test_perm")
self.assertFalse(result)


class TestAsyncPermissionGroup(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.user = UserFactory()
self.request_factory = RequestFactory()
self.name = "test_name"
self.permissions = ["foo", "bar"]
self.check_string = '<Group name="{0}">'.format(self.name)

def tearDown(self):
pass

@mock.patch("tethys_apps.utilities.get_active_app")
async def test_scoped_user_has_permission(self, mock_app):
self.user.has_perm = mock.MagicMock(return_value=True)
scope = {"user": self.user, "path": "some/url/path"}
mock_app.return_value = mock.MagicMock(package="test_package")
result = await tethys_permission.scoped_user_has_permission(
scope=scope, perm="test_perm"
)
self.assertTrue(result)

@mock.patch("tethys_apps.utilities.get_active_app")
async def test_scoped_user_has_permission_no(self, mock_app):
self.user.has_perm = mock.MagicMock(return_value=False)
scope = {"user": self.user, "path": "some/url/path"}
mock_app.return_value = mock.MagicMock(package="test_package")
result = await tethys_permission.scoped_user_has_permission(
scope=scope, perm="test_perm"
)
self.assertFalse(result)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Copyright: (c) Aquaveo 2018
********************************************************************************
"""

from tethys_sdk.testing import TethysTestCase
from tethys_apps.models import (
TethysApp,
Expand Down

0 comments on commit d65dd68

Please sign in to comment.