diff --git a/drunc_ui/settings/settings.py b/drunc_ui/settings/settings.py index e36a999..f6e8d31 100644 --- a/drunc_ui/settings/settings.py +++ b/drunc_ui/settings/settings.py @@ -12,6 +12,8 @@ import os from pathlib import Path +import django_stubs_ext + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -151,3 +153,5 @@ CRISPY_TEMPLATE_PACK = "bootstrap5" KAFKA_ADDRESS = os.getenv("KAFKA_ADDRESS", "kafka:9092") + +django_stubs_ext.monkeypatch() diff --git a/poetry.lock b/poetry.lock index 9248404..06669ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1365,4 +1365,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "0a4b565353601196340ecd3d1df522108d10a47b047d377332f62ce4bdeb33c6" +content-hash = "983b5bf7cc34ce2f31a452917c254e7f4c87ddcdccc0ffc7a354c124d96c220d" diff --git a/process_manager/process_manager_interface.py b/process_manager/process_manager_interface.py new file mode 100644 index 0000000..3b4c95d --- /dev/null +++ b/process_manager/process_manager_interface.py @@ -0,0 +1,104 @@ +"""Module providing functions to interact with the drunc process manager.""" + +import asyncio +from collections.abc import Iterable +from enum import Enum + +from django.conf import settings +from drunc.process_manager.process_manager_driver import ProcessManagerDriver +from drunc.utils.shell_utils import DecodedResponse, create_dummy_token_from_uname +from druncschema.process_manager_pb2 import ( + LogRequest, + ProcessInstanceList, + ProcessQuery, + ProcessUUID, +) + + +def get_process_manager_driver() -> ProcessManagerDriver: + """Get a ProcessManagerDriver instance.""" + token = create_dummy_token_from_uname() + return ProcessManagerDriver( + settings.PROCESS_MANAGER_URL, token=token, aio_channel=True + ) + + +async def _get_session_info() -> ProcessInstanceList: + pmd = get_process_manager_driver() + query = ProcessQuery(names=[".*"]) + return await pmd.ps(query) + + +def get_session_info() -> ProcessInstanceList: + """Get info about all sessions from process manager.""" + return asyncio.run(_get_session_info()) + + +class ProcessAction(Enum): + """Enum for process actions.""" + + RESTART = "restart" + KILL = "kill" + FLUSH = "flush" + + +async def _process_call(uuids: Iterable[str], action: ProcessAction) -> None: + pmd = get_process_manager_driver() + uuids_ = [ProcessUUID(uuid=u) for u in uuids] + + match action: + case ProcessAction.RESTART: + for uuid_ in uuids_: + query = ProcessQuery(uuids=[uuid_]) + await pmd.restart(query) + case ProcessAction.KILL: + query = ProcessQuery(uuids=uuids_) + await pmd.kill(query) + case ProcessAction.FLUSH: + query = ProcessQuery(uuids=uuids_) + await pmd.flush(query) + + +def process_call(uuids: Iterable[str], action: ProcessAction) -> None: + """Perform an action on a process with a given UUID. + + Args: + uuids: List of UUIDs of the process to be actioned. + action: Action to be performed {restart,flush,kill}. + """ + return asyncio.run(_process_call(uuids, action)) + + +async def _get_process_logs(uuid: str) -> list[DecodedResponse]: + pmd = get_process_manager_driver() + query = ProcessQuery(uuids=[ProcessUUID(uuid=uuid)]) + request = LogRequest(query=query, how_far=100) + return [item async for item in pmd.logs(request)] + + +def get_process_logs(uuid: str) -> list[DecodedResponse]: + """Retrieve logs for a process from the process manager. + + Args: + uuid: UUID of the process. + + Returns: + The process logs. + """ + return asyncio.run(_get_process_logs(uuid)) + + +async def _boot_process(user: str, data: dict[str, str | int]) -> None: + pmd = get_process_manager_driver() + async for item in pmd.dummy_boot(user=user, **data): + pass + + +def boot_process(user: str, data: dict[str, str | int]) -> None: + """Boot a process with the given data. + + Args: + user: the user to boot the process as. + data: the data for the process. + """ + return asyncio.run(_boot_process(user, data)) diff --git a/process_manager/urls.py b/process_manager/urls.py index 408cf0b..0378281 100644 --- a/process_manager/urls.py +++ b/process_manager/urls.py @@ -2,18 +2,18 @@ from django.urls import include, path -from . import views +from .views import actions, pages, partials app_name = "process_manager" partial_urlpatterns = [ - path("process_table/", views.process_table, name="process_table"), + path("process_table/", partials.process_table, name="process_table"), ] urlpatterns = [ - path("", views.index, name="index"), - path("process_action/", views.process_action, name="process_action"), - path("logs/", views.logs, name="logs"), - path("boot_process/", views.BootProcessView.as_view(), name="boot_process"), + path("", pages.index, name="index"), + path("process_action/", actions.process_action, name="process_action"), + path("logs/", pages.logs, name="logs"), + path("boot_process/", pages.BootProcessView.as_view(), name="boot_process"), path("partials/", include(partial_urlpatterns)), ] diff --git a/process_manager/views.py b/process_manager/views.py deleted file mode 100644 index 712c59f..0000000 --- a/process_manager/views.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Views for the process_manager app.""" - -import asyncio -import uuid -from enum import Enum - -import django_tables2 -from django.conf import settings -from django.contrib.auth.decorators import login_required, permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db import transaction -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect -from django.shortcuts import render -from django.urls import reverse, reverse_lazy -from django.views.generic.edit import FormView -from drunc.process_manager.process_manager_driver import ProcessManagerDriver -from drunc.utils.shell_utils import DecodedResponse, create_dummy_token_from_uname -from druncschema.process_manager_pb2 import ( - LogRequest, - ProcessInstance, - ProcessInstanceList, - ProcessQuery, - ProcessUUID, -) - -from .forms import BootProcessForm -from .tables import ProcessTable - - -def get_process_manager_driver() -> ProcessManagerDriver: - """Get a ProcessManagerDriver instance.""" - token = create_dummy_token_from_uname() - return ProcessManagerDriver( - settings.PROCESS_MANAGER_URL, token=token, aio_channel=True - ) - - -async def get_session_info() -> ProcessInstanceList: - """Get info about all sessions from process manager.""" - pmd = get_process_manager_driver() - query = ProcessQuery(names=[".*"]) - return await pmd.ps(query) - - -@login_required -def index(request: HttpRequest) -> HttpResponse: - """View that renders the index/home page.""" - with transaction.atomic(): - # atomic to avoid race condition with kafka consumer - messages = request.session.load().get("messages", []) - request.session.pop("messages", []) - request.session.save() - - context = {"messages": messages} - return render( - request=request, context=context, template_name="process_manager/index.html" - ) - - -@login_required -def process_table(request: HttpRequest) -> HttpResponse: - """Renders the process table. - - This view may be called using either GET or POST methods. GET renders the table with - no check boxes selected. POST renders the table with checked boxes for any table row - with a uuid provided in the select key of the request data. - """ - selected_rows = request.POST.getlist("select", []) - session_info = asyncio.run(get_session_info()) - - status_enum_lookup = dict(item[::-1] for item in ProcessInstance.StatusCode.items()) - - table_data = [] - process_instances = session_info.data.values - for process_instance in process_instances: - metadata = process_instance.process_description.metadata - uuid = process_instance.uuid.uuid - table_data.append( - { - "uuid": uuid, - "name": metadata.name, - "user": metadata.user, - "session": metadata.session, - "status_code": status_enum_lookup[process_instance.status_code], - "exit_code": process_instance.return_code, - "checked": (uuid in selected_rows), - } - ) - table = ProcessTable(table_data) - - # sort table data based on request parameters - table_configurator = django_tables2.RequestConfig(request) - table_configurator.configure(table) - - return render( - request=request, - context=dict(table=table), - template_name="process_manager/partials/process_table.html", - ) - - -class ProcessAction(Enum): - """Enum for process actions.""" - - RESTART = "restart" - KILL = "kill" - FLUSH = "flush" - - -async def _process_call(uuids: list[str], action: ProcessAction) -> None: - """Perform an action on a process with a given UUID. - - Args: - uuids: List of UUIDs of the process to be actioned. - action: Action to be performed {restart,flush,kill}. - """ - pmd = get_process_manager_driver() - uuids_ = [ProcessUUID(uuid=u) for u in uuids] - - match action: - case ProcessAction.RESTART: - for uuid_ in uuids_: - query = ProcessQuery(uuids=[uuid_]) - await pmd.restart(query) - case ProcessAction.KILL: - query = ProcessQuery(uuids=uuids_) - await pmd.kill(query) - case ProcessAction.FLUSH: - query = ProcessQuery(uuids=uuids_) - await pmd.flush(query) - - -@login_required -@permission_required("main.can_modify_processes", raise_exception=True) -def process_action(request: HttpRequest) -> HttpResponse: - """Perform an action on the selected processes. - - Both the action and the selected processes are retrieved from the request. - - Args: - request: Django HttpRequest object. - - Returns: - HttpResponse redirecting to the index page. - """ - try: - action = request.POST.get("action", "") - action_enum = ProcessAction(action.lower()) - except ValueError: - return HttpResponseRedirect(reverse("process_manager:index")) - - if uuids_ := request.POST.getlist("select"): - asyncio.run(_process_call(uuids_, action_enum)) - return HttpResponseRedirect(reverse("process_manager:index")) - - -async def _get_process_logs(uuid: str) -> list[DecodedResponse]: - """Retrieve logs for a process from the process manager. - - Args: - uuid: UUID of the process. - - Returns: - The process logs. - """ - pmd = get_process_manager_driver() - query = ProcessQuery(uuids=[ProcessUUID(uuid=uuid)]) - request = LogRequest(query=query, how_far=100) - return [item async for item in pmd.logs(request)] - - -@login_required -@permission_required("main.can_view_process_logs", raise_exception=True) -def logs(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: - """Display the logs of a process. - - Args: - request: the triggering request. - uuid: identifier for the process. - - Returns: - The rendered page. - """ - logs_response = asyncio.run(_get_process_logs(str(uuid))) - context = dict(log_text="\n".join(val.data.line for val in logs_response)) - return render( - request=request, context=context, template_name="process_manager/logs.html" - ) - - -async def _boot_process(user: str, data: dict[str, str | int]) -> None: - """Boot a process with the given data. - - Args: - user: the user to boot the process as. - data: the data for the process. - """ - pmd = get_process_manager_driver() - async for item in pmd.dummy_boot(user=user, **data): - pass - - -class BootProcessView(PermissionRequiredMixin, FormView): # type: ignore [type-arg] - """View for the BootProcess form.""" - - template_name = "process_manager/boot_process.html" - form_class = BootProcessForm - success_url = reverse_lazy("process_manager:index") - permission_required = "main.can_modify_processes" - - def form_valid(self, form: BootProcessForm) -> HttpResponse: - """Boot a Process when valid form data has been POSTed. - - Args: - form: the form instance that has been validated. - - Returns: - A redirect to the index page. - """ - asyncio.run(_boot_process("root", form.cleaned_data)) - return super().form_valid(form) diff --git a/process_manager/views/__init__.py b/process_manager/views/__init__.py new file mode 100644 index 0000000..00c1b69 --- /dev/null +++ b/process_manager/views/__init__.py @@ -0,0 +1 @@ +"""Module for app view functions.""" diff --git a/process_manager/views/actions.py b/process_manager/views/actions.py new file mode 100644 index 0000000..a54fb77 --- /dev/null +++ b/process_manager/views/actions.py @@ -0,0 +1,32 @@ +"""View functions for performing actions on DUNE processes.""" + +from django.contrib.auth.decorators import login_required, permission_required +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.urls import reverse + +from ..process_manager_interface import ProcessAction, process_call + + +@login_required +@permission_required("main.can_modify_processes", raise_exception=True) +def process_action(request: HttpRequest) -> HttpResponse: + """Perform an action on the selected processes. + + Both the action and the selected processes are retrieved from the request. + + Args: + request: Django HttpRequest object. + + Returns: + HttpResponse redirecting to the index page. + """ + try: + action = request.POST.get("action", "") + action_enum = ProcessAction(action.lower()) + except ValueError: + # action.lower() is not a valid enum value + return HttpResponseRedirect(reverse("process_manager:index")) + + if uuids_ := request.POST.getlist("select"): + process_call(uuids_, action_enum) + return HttpResponseRedirect(reverse("process_manager:index")) diff --git a/process_manager/views/pages.py b/process_manager/views/pages.py new file mode 100644 index 0000000..a2bed55 --- /dev/null +++ b/process_manager/views/pages.py @@ -0,0 +1,69 @@ +"""View functions for pages.""" + +import uuid + +from django.contrib.auth.decorators import login_required, permission_required +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db import transaction +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic.edit import FormView + +from ..forms import BootProcessForm +from ..process_manager_interface import boot_process, get_process_logs + + +@login_required +def index(request: HttpRequest) -> HttpResponse: + """View that renders the index/home page.""" + with transaction.atomic(): + # atomic to avoid race condition with kafka consumer + messages = request.session.load().get("messages", []) + request.session.pop("messages", []) + request.session.save() + + context = {"messages": messages} + return render( + request=request, context=context, template_name="process_manager/index.html" + ) + + +@login_required +@permission_required("main.can_view_process_logs", raise_exception=True) +def logs(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: + """Display the logs of a process. + + Args: + request: the triggering request. + uuid: identifier for the process. + + Returns: + The rendered page. + """ + logs_response = get_process_logs(str(uuid)) + context = dict(log_text="\n".join(val.data.line for val in logs_response)) + return render( + request=request, context=context, template_name="process_manager/logs.html" + ) + + +class BootProcessView(PermissionRequiredMixin, FormView[BootProcessForm]): + """View for the BootProcess form.""" + + template_name = "process_manager/boot_process.html" + form_class = BootProcessForm + success_url = reverse_lazy("process_manager:index") + permission_required = "main.can_modify_processes" + + def form_valid(self, form: BootProcessForm) -> HttpResponse: + """Boot a Process when valid form data has been POSTed. + + Args: + form: the form instance that has been validated. + + Returns: + A redirect to the index page. + """ + boot_process("root", form.cleaned_data) + return super().form_valid(form) diff --git a/process_manager/views/partials.py b/process_manager/views/partials.py new file mode 100644 index 0000000..ab5d5b5 --- /dev/null +++ b/process_manager/views/partials.py @@ -0,0 +1,54 @@ +"""View functions for partials.""" + +import django_tables2 +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from druncschema.process_manager_pb2 import ( + ProcessInstance, +) + +from ..process_manager_interface import get_session_info +from ..tables import ProcessTable + + +@login_required +def process_table(request: HttpRequest) -> HttpResponse: + """Renders the process table. + + This view may be called using either GET or POST methods. GET renders the table with + no check boxes selected. POST renders the table with checked boxes for any table row + with a uuid provided in the select key of the request data. + """ + selected_rows = request.POST.getlist("select", []) + session_info = get_session_info() + + status_enum_lookup = dict(item[::-1] for item in ProcessInstance.StatusCode.items()) + + table_data = [] + process_instances = session_info.data.values + for process_instance in process_instances: + metadata = process_instance.process_description.metadata + uuid = process_instance.uuid.uuid + table_data.append( + { + "uuid": uuid, + "name": metadata.name, + "user": metadata.user, + "session": metadata.session, + "status_code": status_enum_lookup[process_instance.status_code], + "exit_code": process_instance.return_code, + "checked": (uuid in selected_rows), + } + ) + table = ProcessTable(table_data) + + # sort table data based on request parameters + table_configurator = django_tables2.RequestConfig(request) + table_configurator.configure(table) + + return render( + request=request, + context=dict(table=table), + template_name="process_manager/partials/process_table.html", + ) diff --git a/pyproject.toml b/pyproject.toml index fb0641b..5f170b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ django-bootstrap5 = "^24.3" pytest-asyncio = "^0.24.0" django-crispy-forms = "^2.3" crispy-bootstrap5 = "^2024.10" +django-stubs-ext = "^5.1.0" [tool.poetry.group.dev.dependencies] pytest = "^8.3" diff --git a/tests/conftest.py b/tests/conftest.py index d7a7a7c..b24ae6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,29 +14,40 @@ def auth_client(django_user_model) -> Client: return client -@pytest.fixture -def auth_process_client(django_user_model) -> Client: - """Return a authenticated client with modify process privilege.""" - user = django_user_model.objects.create(username="process_user") - permission = Permission.objects.get(codename="can_modify_processes") +def _privileged_user_client(django_user_model, username, permission_name): + user = django_user_model.objects.create(username=username) + permission = Permission.objects.get(codename=permission_name) user.user_permissions.add(permission) client = Client() client.force_login(user) return client +@pytest.fixture +def auth_process_client(django_user_model) -> Client: + """Return a authenticated client with modify process privilege.""" + return _privileged_user_client( + django_user_model, "process_user", "can_modify_processes" + ) + + @pytest.fixture def auth_logs_client(django_user_model) -> Client: """Return a authenticated client with view logs privilege.""" - user = django_user_model.objects.create(username="logs_user") - permission = Permission.objects.get(codename="can_view_process_logs") - user.user_permissions.add(permission) - client = Client() - client.force_login(user) - return client + return _privileged_user_client( + django_user_model, "logs_user", "can_view_process_logs" + ) @pytest.fixture def dummy_session_data() -> dict[str, str | int]: """A dictionary of dummy data to populate a dummy session.""" return dict(session_name="sess_name", n_processes=1, sleep=5, n_sleeps=4) + + +@pytest.fixture(autouse=True) +def grpc_mock(mocker): + """Mock out the method that generates gRPC calls to external interfaces.""" + yield mocker.patch( + "process_manager.process_manager_interface.ProcessManagerDriver.send_command_aio" + ) diff --git a/tests/process_manager/test_views.py b/tests/process_manager/test_views.py deleted file mode 100644 index 24e880a..0000000 --- a/tests/process_manager/test_views.py +++ /dev/null @@ -1,211 +0,0 @@ -from http import HTTPStatus -from unittest.mock import MagicMock -from uuid import uuid4 - -import pytest -from django.urls import reverse -from pytest_django.asserts import assertContains, assertTemplateUsed - -from process_manager.tables import ProcessTable -from process_manager.views import ProcessAction - -from ..utils import LoginRequiredTest - - -class TestIndexView(LoginRequiredTest): - """Tests for the index view.""" - - endpoint = reverse("process_manager:index") - - def test_index_view_authenticated(self, auth_client, mocker): - """Test the index view for an authenticated user.""" - mocker.patch("process_manager.views.get_session_info") - with assertTemplateUsed(template_name="process_manager/index.html"): - response = auth_client.get(self.endpoint) - assert response.status_code == HTTPStatus.OK - - def test_index_view_admin(self, admin_client, mocker): - """Test the index view for an admin user.""" - mocker.patch("process_manager.views.get_session_info") - with assertTemplateUsed(template_name="process_manager/index.html"): - response = admin_client.get(self.endpoint) - assert response.status_code == HTTPStatus.OK - assertContains(response, "Boot") - - def test_session_messages(self, auth_client, mocker): - """Test the rendering of messages from the user session into the view.""" - from django.contrib.sessions.backends.db import SessionStore - from django.contrib.sessions.models import Session - - mocker.patch("process_manager.views.get_session_info") - session = Session.objects.get() - message_data = ["message 1", "message 2"] - store = SessionStore(session_key=session.session_key) - store["messages"] = message_data - store.save() - - response = auth_client.get(self.endpoint) - assert response.status_code == HTTPStatus.OK - - # messages have been removed from the session and added to the context - assert response.context["messages"] == message_data - assert "messages" not in store.load() - - -class TestLogsView(LoginRequiredTest): - """Tests for the logs view.""" - - uuid = uuid4() - endpoint = reverse("process_manager:logs", kwargs=dict(uuid=uuid)) - - def test_logs_view_unprivileged(self, auth_client): - """Test the logs view for an unprivileged user.""" - response = auth_client.get(self.endpoint) - assert response.status_code == HTTPStatus.FORBIDDEN - - def test_logs_view_privileged(self, auth_logs_client, mocker): - """Test the logs view for a privileged user.""" - mock = mocker.patch("process_manager.views._get_process_logs") - with assertTemplateUsed(template_name="process_manager/logs.html"): - response = auth_logs_client.get(self.endpoint) - assert response.status_code == HTTPStatus.OK - - mock.assert_called_once_with(str(self.uuid)) - assert "log_text" in response.context - - -class TestProcessActionView(LoginRequiredTest): - """Tests for the process_action view.""" - - endpoint = reverse("process_manager:process_action") - - def test_process_action_no_action(self, auth_process_client): - """Test process_action view with no action provided.""" - response = auth_process_client.post(self.endpoint, data={}) - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("process_manager:index") - - def test_process_action_invalid_action(self, auth_process_client): - """Test process_action view with an invalid action.""" - response = auth_process_client.post( - self.endpoint, data={"action": "invalid_action"} - ) - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("process_manager:index") - - @pytest.mark.parametrize("action", ["kill", "restart", "flush"]) - def test_process_action_valid_action(self, action, auth_process_client, mocker): - """Test process_action view with a valid action.""" - mock = mocker.patch("process_manager.views._process_call") - uuids_ = [str(uuid4()), str(uuid4())] - response = auth_process_client.post( - self.endpoint, data={"action": action, "select": uuids_} - ) - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("process_manager:index") - - mock.assert_called_once_with(uuids_, ProcessAction(action)) - - def test_process_action_get_unprivileged(self, auth_client): - """Test the GET request for the process_action view (unprivileged).""" - response = auth_client.get(reverse("process_manager:boot_process")) - assert response.status_code == HTTPStatus.FORBIDDEN - - -class TestBootProcess(LoginRequiredTest): - """Grouping the tests for the BootProcess view.""" - - template_name = "process_manager/boot_process.html" - endpoint = reverse("process_manager:boot_process") - - def test_boot_process_get_unprivileged(self, auth_client): - """Test the GET request for the BootProcess view (unprivileged).""" - response = auth_client.get(reverse("process_manager:boot_process")) - assert response.status_code == HTTPStatus.FORBIDDEN - - def test_boot_process_get_privileged(self, auth_process_client): - """Test the GET request for the BootProcess view (privileged).""" - with assertTemplateUsed(template_name=self.template_name): - response = auth_process_client.get(reverse("process_manager:boot_process")) - assert response.status_code == HTTPStatus.OK - - assert "form" in response.context - assertContains( - response, f'form action="{reverse("process_manager:boot_process")}"' - ) - - def test_boot_process_post_invalid(self, auth_process_client): - """Test the POST request for the BootProcess view with invalid data.""" - with assertTemplateUsed(template_name=self.template_name): - response = auth_process_client.post( - reverse("process_manager:boot_process"), data=dict() - ) - assert response.status_code == HTTPStatus.OK - - assert "form" in response.context - - def test_boot_process_post_valid( - self, auth_process_client, mocker, dummy_session_data - ): - """Test the POST request for the BootProcess view.""" - mock = mocker.patch("process_manager.views._boot_process") - response = auth_process_client.post( - reverse("process_manager:boot_process"), data=dummy_session_data - ) - assert response.status_code == HTTPStatus.FOUND - - assert response.url == reverse("process_manager:index") - - mock.assert_called_once_with("root", dummy_session_data) - - -@pytest.mark.asyncio -async def test_boot_process(mocker, dummy_session_data): - """Test the _boot_process function.""" - from process_manager.views import _boot_process - - mock = mocker.patch("process_manager.views.get_process_manager_driver") - await _boot_process("root", dummy_session_data) - mock.assert_called_once() - mock.return_value.dummy_boot.assert_called_once_with( - user="root", **dummy_session_data - ) - - -class TestProcessTableView(LoginRequiredTest): - """Test the process_manager.views.process_table view function.""" - - endpoint = reverse("process_manager:process_table") - - @pytest.mark.parametrize("method", ("get", "post")) - def test_method(self, method, auth_client, mocker): - """Tests basic calls of view method.""" - self._mock_session_info(mocker, []) - response = getattr(auth_client, method)(self.endpoint) - assert response.status_code == HTTPStatus.OK - assert isinstance(response.context["table"], ProcessTable) - - def _mock_session_info(self, mocker, uuids): - """Mocks views.get_session_info with ProcessInstanceList like data.""" - mock = mocker.patch("process_manager.views.get_session_info") - instance_mocks = [MagicMock() for uuid in uuids] - for instance_mock, uuid in zip(instance_mocks, uuids): - instance_mock.uuid.uuid = str(uuid) - instance_mock.status_code = 0 - mock.data.values.__iter__.return_value = instance_mocks - return mock - - def test_post_checked_rows(self, mocker, auth_client): - """Tests table data is correct when post data is included.""" - all_uuids = [str(uuid4()) for _ in range(5)] - selected_uuids = all_uuids[::2] - - self._mock_session_info(mocker, all_uuids) - - response = auth_client.post(self.endpoint, data=dict(select=selected_uuids)) - assert response.status_code == HTTPStatus.OK - table = response.context["table"] - assert isinstance(table, ProcessTable) - - for row in table.data.data: - assert row["checked"] == (row["uuid"] in selected_uuids) diff --git a/tests/process_manager/views/__init__.py b/tests/process_manager/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/process_manager/views/test_action_views.py b/tests/process_manager/views/test_action_views.py new file mode 100644 index 0000000..1290888 --- /dev/null +++ b/tests/process_manager/views/test_action_views.py @@ -0,0 +1,42 @@ +from http import HTTPStatus +from uuid import uuid4 + +import pytest +from django.urls import reverse + +from process_manager.views.actions import ProcessAction + +from ...utils import PermissionRequiredTest + + +class TestProcessActionView(PermissionRequiredTest): + """Tests for the process_action view.""" + + endpoint = reverse("process_manager:process_action") + + def test_no_action(self, auth_process_client): + """Test process_action view with no action provided.""" + response = auth_process_client.post(self.endpoint, data={}) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + def test_invalid_action(self, auth_process_client): + """Test process_action view with an invalid action.""" + response = auth_process_client.post( + self.endpoint, data={"action": "invalid_action"} + ) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + @pytest.mark.parametrize("action", ["kill", "restart", "flush"]) + def test_valid_action(self, action, auth_process_client, mocker): + """Test process_action view with a valid action.""" + mock = mocker.patch("process_manager.views.actions.process_call") + uuids_ = [str(uuid4()), str(uuid4())] + response = auth_process_client.post( + self.endpoint, data={"action": action, "select": uuids_} + ) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + mock.assert_called_once_with(uuids_, ProcessAction(action)) diff --git a/tests/process_manager/views/test_page_views.py b/tests/process_manager/views/test_page_views.py new file mode 100644 index 0000000..0ad5da7 --- /dev/null +++ b/tests/process_manager/views/test_page_views.py @@ -0,0 +1,94 @@ +from http import HTTPStatus +from uuid import uuid4 + +from django.urls import reverse +from pytest_django.asserts import assertContains, assertTemplateUsed + +from ...utils import LoginRequiredTest, PermissionRequiredTest + + +class TestIndexView(LoginRequiredTest): + """Tests for the index view.""" + + endpoint = reverse("process_manager:index") + + def test_authenticated(self, auth_client): + """Test the index view for an authenticated user.""" + with assertTemplateUsed(template_name="process_manager/index.html"): + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + + def test_session_messages(self, auth_client): + """Test the rendering of messages from the user session into the view.""" + from django.contrib.sessions.backends.db import SessionStore + from django.contrib.sessions.models import Session + + session = Session.objects.get() + message_data = ["message 1", "message 2"] + store = SessionStore(session_key=session.session_key) + store["messages"] = message_data + store.save() + + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + + # messages have been removed from the session and added to the context + assert response.context["messages"] == message_data + assert "messages" not in store.load() + + +class TestLogsView(PermissionRequiredTest): + """Tests for the logs view.""" + + uuid = uuid4() + endpoint = reverse("process_manager:logs", kwargs=dict(uuid=uuid)) + + def test_get(self, auth_logs_client, mocker): + """Test the logs view for a privileged user.""" + mock = mocker.patch("process_manager.views.pages.get_process_logs") + with assertTemplateUsed(template_name="process_manager/logs.html"): + response = auth_logs_client.get(self.endpoint) + assert response.status_code == HTTPStatus.OK + + mock.assert_called_once_with(str(self.uuid)) + assert "log_text" in response.context + + +class TestBootProcess(PermissionRequiredTest): + """Grouping the tests for the BootProcess view.""" + + template_name = "process_manager/boot_process.html" + endpoint = reverse("process_manager:boot_process") + + def test_get_privileged(self, auth_process_client): + """Test the GET request for the BootProcess view (privileged).""" + with assertTemplateUsed(template_name=self.template_name): + response = auth_process_client.get(reverse("process_manager:boot_process")) + assert response.status_code == HTTPStatus.OK + + assert "form" in response.context + assertContains( + response, f'form action="{reverse("process_manager:boot_process")}"' + ) + + def test_post_invalid(self, auth_process_client): + """Test the POST request for the BootProcess view with invalid data.""" + with assertTemplateUsed(template_name=self.template_name): + response = auth_process_client.post( + reverse("process_manager:boot_process"), data=dict() + ) + assert response.status_code == HTTPStatus.OK + + assert "form" in response.context + + def test_post_valid(self, auth_process_client, mocker, dummy_session_data): + """Test the POST request for the BootProcess view.""" + mock = mocker.patch("process_manager.views.pages.boot_process") + response = auth_process_client.post( + reverse("process_manager:boot_process"), data=dummy_session_data + ) + assert response.status_code == HTTPStatus.FOUND + + assert response.url == reverse("process_manager:index") + + mock.assert_called_once_with("root", dummy_session_data) diff --git a/tests/process_manager/views/test_partial_views.py b/tests/process_manager/views/test_partial_views.py new file mode 100644 index 0000000..55657a6 --- /dev/null +++ b/tests/process_manager/views/test_partial_views.py @@ -0,0 +1,49 @@ +from http import HTTPStatus +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from django.urls import reverse + +from process_manager.tables import ProcessTable + +from ...utils import LoginRequiredTest + + +class TestProcessTableView(LoginRequiredTest): + """Test the process_manager.views.process_table view function.""" + + endpoint = reverse("process_manager:process_table") + + @pytest.mark.parametrize("method", ("get", "post")) + def test_method(self, method, auth_client, mocker): + """Tests basic calls of view method.""" + self._mock_session_info(mocker, []) + response = getattr(auth_client, method)(self.endpoint) + assert response.status_code == HTTPStatus.OK + assert isinstance(response.context["table"], ProcessTable) + + def _mock_session_info(self, mocker, uuids): + """Mocks views.get_session_info with ProcessInstanceList like data.""" + mock = mocker.patch("process_manager.views.partials.get_session_info") + instance_mocks = [MagicMock() for uuid in uuids] + for instance_mock, uuid in zip(instance_mocks, uuids): + instance_mock.uuid.uuid = str(uuid) + instance_mock.status_code = 0 + mock.data.values.__iter__.return_value = instance_mocks + return mock + + def test_post_checked_rows(self, mocker, auth_client): + """Tests table data is correct when post data is included.""" + all_uuids = [str(uuid4()) for _ in range(5)] + selected_uuids = all_uuids[::2] + + self._mock_session_info(mocker, all_uuids) + + response = auth_client.post(self.endpoint, data=dict(select=selected_uuids)) + assert response.status_code == HTTPStatus.OK + table = response.context["table"] + assert isinstance(table, ProcessTable) + + for row in table.data.data: + assert row["checked"] == (row["uuid"] in selected_uuids) diff --git a/tests/test_process_manager_interface.py b/tests/test_process_manager_interface.py new file mode 100644 index 0000000..ceb7955 --- /dev/null +++ b/tests/test_process_manager_interface.py @@ -0,0 +1,13 @@ +from process_manager.process_manager_interface import boot_process + + +def test_boot_process(mocker, dummy_session_data): + """Test the _boot_process function.""" + mock = mocker.patch( + "process_manager.process_manager_interface.get_process_manager_driver" + ) + boot_process("root", dummy_session_data) + mock.assert_called_once() + mock.return_value.dummy_boot.assert_called_once_with( + user="root", **dummy_session_data + ) diff --git a/tests/utils.py b/tests/utils.py index 000b0ce..a164d64 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,3 +15,12 @@ def test_login_redirect(self, client): assert response.status_code == HTTPStatus.FOUND assertRedirects(response, reverse("main:login") + f"?next={self.endpoint}") + + +class PermissionRequiredTest(LoginRequiredTest): + """Tests for views that require authentication and correct user permissions.""" + + def test_permission_deny(self, auth_client): + """Test that authenticated users missing permissions are blocked.""" + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.FORBIDDEN