From c09d2d989e08e35bf4e83472206e97cfac5e1dfb Mon Sep 17 00:00:00 2001 From: Juha Louhiranta Date: Wed, 21 Aug 2024 10:12:22 +0300 Subject: [PATCH] feat: add audit logging for BaseCommentViewSet Audit logged functionality: - (un)vote - flag - create - update - delete - retrieve - list Refs: KER-368 --- .../tests/integrationtest/test_comment.py | 78 +++++++++++++++++++ .../integrationtest/test_comment_vote.py | 20 +++++ democracy/tests/utils.py | 18 +++++ democracy/views/comment.py | 9 ++- kerrokantasi/tests/conftest.py | 5 ++ 5 files changed, 129 insertions(+), 1 deletion(-) diff --git a/democracy/tests/integrationtest/test_comment.py b/democracy/tests/integrationtest/test_comment.py index afe29c8d..04ac4b1a 100644 --- a/democracy/tests/integrationtest/test_comment.py +++ b/democracy/tests/integrationtest/test_comment.py @@ -8,7 +8,9 @@ from django.utils.timezone import now from rest_framework import status from reversion.models import Version +from urllib.parse import urlparse +from audit_log.enums import Operation from democracy.enums import Commenting, InitialSectionType from democracy.factories.hearing import SectionCommentFactory from democracy.factories.poll import SectionPollFactory @@ -16,6 +18,7 @@ from democracy.models.section import SectionComment, SectionPoll, SectionPollAnswer from democracy.tests.conftest import default_comment_content, default_lang_code from democracy.tests.utils import ( + assert_audit_log_entry, assert_common_keys_equal, get_data_from_response, get_hearing_detail_url, @@ -1684,3 +1687,78 @@ def test_hearing_sections_comment_num_queries( with django_assert_num_queries(7): response = john_doe_api_client.get(url) get_data_from_response(response, 200) + + +@pytest.mark.django_db +def test_comment_id_is_audit_logged_on_flag(john_smith_api_client, default_hearing, audit_log_configure): + section = default_hearing.sections.first() + comment = section.comments.first() + url = get_section_comment_flag_url(default_hearing.id, section.id, comment.id) + + john_smith_api_client.post(url) + + assert_audit_log_entry("/flag", [comment.pk]) + + +@pytest.mark.django_db +def test_comment_id_is_audit_logged_on_create( + john_doe_api_client, default_hearing, get_comments_url_and_data, audit_log_configure +): + section = default_hearing.sections.first() + url, data = get_comments_url_and_data(default_hearing, section) + + response = john_doe_api_client.post(url, data=data) + data = get_data_from_response(response, 201) + + assert_audit_log_entry(urlparse(url).path, [data["id"]]) + + +@pytest.mark.django_db +def test_comment_id_is_audit_logged_on_edit(john_doe_api_client, default_hearing, get_detail_url, audit_log_configure): + section = default_hearing.get_main_section() + comment = section.comments.all()[0] + url = get_detail_url(comment) + + john_doe_api_client.patch(url, data={"content": "B"}) + + assert_audit_log_entry(url, [comment.pk], operation=Operation.UPDATE) + + +@pytest.mark.django_db +def test_comment_id_is_audit_logged_on_delete( + john_doe_api_client, default_hearing, get_detail_url, audit_log_configure +): + section = default_hearing.get_main_section() + comment = section.comments.all()[0] + url = get_detail_url(comment) + + john_doe_api_client.delete(url) + + assert_audit_log_entry(url, [comment.pk], operation=Operation.DELETE) + + +@pytest.mark.django_db +def test_comment_id_is_audit_logged_on_retrieve( + john_doe_api_client, default_hearing, get_detail_url, audit_log_configure +): + section = default_hearing.get_main_section() + comment = section.comments.all()[0] + url = get_detail_url(comment) + + john_doe_api_client.get(url) + + assert_audit_log_entry(url, [comment.pk], operation=Operation.READ) + + +@pytest.mark.django_db +def test_comment_ids_are_audit_logged_on_list( + john_doe_api_client, default_hearing, get_comments_url_and_data, audit_log_configure +): + section = default_hearing.get_main_section() + url, data = get_comments_url_and_data(default_hearing, section) + comments = section.comments.all() + assert comments.count() > 1 + + john_doe_api_client.get(url) + + assert_audit_log_entry(urlparse(url).path, comments.values_list("pk", flat=True), operation=Operation.READ) diff --git a/democracy/tests/integrationtest/test_comment_vote.py b/democracy/tests/integrationtest/test_comment_vote.py index cc42e614..0dcd7210 100644 --- a/democracy/tests/integrationtest/test_comment_vote.py +++ b/democracy/tests/integrationtest/test_comment_vote.py @@ -3,6 +3,7 @@ from democracy.enums import Commenting, InitialSectionType from democracy.models import Section, SectionComment, SectionType from democracy.tests.integrationtest.test_images import get_hearing_detail_url +from democracy.tests.utils import assert_audit_log_entry default_content = "Awesome comment to vote." comment_data = {"content": default_content, "section": None} @@ -125,3 +126,22 @@ def test_vote_appears_in_user_data(john_doe_api_client, default_hearing): john_doe_api_client.post(get_section_comment_vote_url(default_hearing.id, section.id, sc_comment.id)) response = john_doe_api_client.get("/v1/users/") assert sc_comment.id in response.data[0]["voted_section_comments"] + + +@pytest.mark.django_db +def test_comment_id_is_audit_logged_on_vote(john_doe_api_client, default_hearing, audit_log_configure): + section, comment = add_default_section_and_comment(default_hearing) + + john_doe_api_client.post(get_section_comment_vote_url(default_hearing.id, section.id, comment.id)) + + assert_audit_log_entry("/vote", [comment.pk]) + + +@pytest.mark.django_db +def test_comment_id_is_audit_logged_on_unvote(api_client, john_doe_api_client, default_hearing, audit_log_configure): + section, comment = add_default_section_and_comment(default_hearing) + + john_doe_api_client.post(get_section_comment_vote_url(default_hearing.id, section.id, comment.id)) + john_doe_api_client.post(get_section_comment_unvote_url(default_hearing.id, section.id, comment.id)) + + assert_audit_log_entry("/unvote", [comment.pk], 2) diff --git a/democracy/tests/utils.py b/democracy/tests/utils.py index a546ff39..04b7931c 100644 --- a/democracy/tests/utils.py +++ b/democracy/tests/utils.py @@ -1,10 +1,13 @@ import json import os +from collections import Counter from django.utils.dateparse import parse_datetime from io import BytesIO from PIL import Image from typing import Iterable, Mapping +from audit_log.enums import Operation, Status +from audit_log.models import AuditLogEntry from democracy.models.files import BaseFile from democracy.models.images import BaseImage from democracy.utils.file_to_base64 import file_to_base64 @@ -215,3 +218,18 @@ def get_nested(data: Mapping, keys: Iterable): def instance_ids(instances: Iterable) -> set: """Get a set of unique IDs from an iterable of model instances, e.g. a list, queryset.""" return {instance.id for instance in instances} + + +def assert_audit_log_entry( + path: str, + object_ids: list, + count: int = 1, + status: Status = Status.SUCCESS, + operation: Operation = Operation.CREATE, +): + assert AuditLogEntry.objects.count() == count + audit_log_entry = AuditLogEntry.objects.order_by("-created_at").first() + assert path in audit_log_entry.message["audit_event"]["target"]["path"] + assert Counter(audit_log_entry.message["audit_event"]["target"]["object_ids"]) == Counter(object_ids) + assert audit_log_entry.message["audit_event"]["status"] == status.value + assert audit_log_entry.message["audit_event"]["operation"] == operation.value diff --git a/democracy/views/comment.py b/democracy/views/comment.py index a7be4642..7cd2614a 100644 --- a/democracy/views/comment.py +++ b/democracy/views/comment.py @@ -9,6 +9,8 @@ from rest_framework.settings import api_settings from reversion.views import RevisionMixin +from audit_log.utils import add_audit_logged_object_ids +from audit_log.views import AuditLogApiView from democracy.models.comment import BaseComment from democracy.renderers import GeoJSONRenderer from democracy.views.base import AdminsSeeUnpublishedMixin, CreatedBySerializer @@ -80,7 +82,7 @@ class Meta: ] -class BaseCommentViewSet(AdminsSeeUnpublishedMixin, RevisionMixin, viewsets.ModelViewSet): +class BaseCommentViewSet(AdminsSeeUnpublishedMixin, RevisionMixin, AuditLogApiView, viewsets.ModelViewSet): """ Base viewset for comments. """ @@ -190,6 +192,7 @@ def create(self, request, *args, **kwargs): if self.request.user.is_authenticated: kwargs["created_by"] = self.request.user comment = serializer.save(**kwargs) + add_audit_logged_object_ids(self.request, serializer.instance) reversion.set_comment("Comment created") # and another for the response serializer = self.get_serializer(instance=comment) @@ -258,6 +261,7 @@ def destroy(self, request, *args, **kwargs): ) instance.soft_delete(user=request.user) + add_audit_logged_object_ids(self.request, instance) reversion.set_comment("Comment deleted") return response.Response(status=status.HTTP_204_NO_CONTENT) @@ -279,6 +283,7 @@ def vote(self, request, **kwargs): return response.Response({"status": "Already voted"}, status=status.HTTP_304_NOT_MODIFIED) # add voter comment.voters.add(request.user) + add_audit_logged_object_ids(self.request, comment) # update number of votes comment.recache_n_votes() # return success @@ -299,6 +304,7 @@ def flag(self, request, **kwargs): instance.flagged_at = timezone.now() instance.flagged_by = request.user instance.save() + add_audit_logged_object_ids(self.request, instance) return response.Response({"status": "comment flagged"}) @action(detail=True, methods=["post"]) @@ -313,6 +319,7 @@ def unvote(self, request, **kwargs): if comment.__class__.objects.filter(id=comment.id, voters=request.user).exists(): # remove voter comment.voters.remove(request.user) + add_audit_logged_object_ids(self.request, comment) # update number of votes comment.recache_n_votes() # return success diff --git a/kerrokantasi/tests/conftest.py b/kerrokantasi/tests/conftest.py index 33af3c16..e1819fa8 100644 --- a/kerrokantasi/tests/conftest.py +++ b/kerrokantasi/tests/conftest.py @@ -28,6 +28,11 @@ def pytest_configure(): ) +@pytest.fixture +def audit_log_configure(settings): + settings.AUDIT_LOG = {"ENABLED": True} + + @pytest.fixture(autouse=True) def setup_test_media(settings): """Create folder for test media/file uploads."""