From 3cbb5b7fcdade09451023a14c869126a654b9eef Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Mon, 5 May 2025 15:36:36 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8(backend)=20force=20loading=20cele?= =?UTF-8?q?ry=20shared=20task=20in=20libraries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library we are using can have celery shared task. We have to make some modification to load them earlier when the celery app is configure and when the impress app is loaded. --- src/backend/impress/__init__.py | 5 +++++ src/backend/impress/celery_app.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/backend/impress/__init__.py b/src/backend/impress/__init__.py index e69de29bb..36f27b88c 100644 --- a/src/backend/impress/__init__.py +++ b/src/backend/impress/__init__.py @@ -0,0 +1,5 @@ +"""Impress package. Import the celery app early to load shared task form dependencies.""" + +from .celery_app import app as celery_app + +__all__ = ["celery_app"] diff --git a/src/backend/impress/celery_app.py b/src/backend/impress/celery_app.py index 37d7a70d9..e38c57071 100644 --- a/src/backend/impress/celery_app.py +++ b/src/backend/impress/celery_app.py @@ -11,6 +11,9 @@ install(check_options=True) +# Can not be loaded only after install call. +from django.conf import settings # pylint: disable=wrong-import-position + app = Celery("impress") # Using a string here means the worker doesn't have to serialize @@ -20,4 +23,4 @@ app.config_from_object("django.conf:settings", namespace="CELERY") # Load task modules from all registered Django apps. -app.autodiscover_tasks() +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) From 1c19d1c1c9cddf51beee4ca811ae649824b32df6 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Mon, 5 May 2025 15:58:36 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8(backend)=20configure=20lasuite.ma?= =?UTF-8?q?lware=5Fdetection=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to use the malware_detection module from lasuite library. We add a new setting MALWARE_DETECTION to configure the backend we want to use. The callback is also added. It removes the file if it is not safe or change it's status in the metadata to set it as ready. --- docs/env.md | 2 + src/backend/core/enums.py | 8 ++ src/backend/core/malware_detection.py | 51 +++++++++++++ .../core/tests/test_malware_detection.py | 76 +++++++++++++++++++ src/backend/impress/settings.py | 16 ++++ src/backend/pyproject.toml | 2 +- 6 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/malware_detection.py create mode 100644 src/backend/core/tests/test_malware_detection.py diff --git a/docs/env.md b/docs/env.md index cd2aafe33..d983e44ba 100644 --- a/docs/env.md +++ b/docs/env.md @@ -98,3 +98,5 @@ These are the environmental variables you can set for the impress-backend contai | DJANGO_CSRF_TRUSTED_ORIGINS | CSRF trusted origins | [] | | REDIS_URL | cache url | redis://redis:6379/1 | | CACHES_DEFAULT_TIMEOUT | cache default timeout | 30 | +| MALWARE_DETECTION_BACKEND | The malware detection backend use from the django-lasuite package | lasuite.malware_detection.backends.dummy.DummyBackend | +| MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | \ No newline at end of file diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index 78cac40a5..46e62b2c2 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -3,6 +3,7 @@ """ import re +from enum import StrEnum from django.conf import global_settings, settings from django.db import models @@ -38,3 +39,10 @@ class MoveNodePositionChoices(models.TextChoices): LAST_SIBLING = "last-sibling", _("Last sibling") LEFT = "left", _("Left") RIGHT = "right", _("Right") + + +class DocumentAttachmentStatus(StrEnum): + """Defines the possible statuses for an attachment.""" + + PROCESSING = "processing" + READY = "ready" diff --git a/src/backend/core/malware_detection.py b/src/backend/core/malware_detection.py new file mode 100644 index 000000000..e67617d86 --- /dev/null +++ b/src/backend/core/malware_detection.py @@ -0,0 +1,51 @@ +"""Malware detection callbacks""" + +import logging + +from django.core.files.storage import default_storage + +from lasuite.malware_detection.enums import ReportStatus + +from core.enums import DocumentAttachmentStatus +from core.models import Document + +logger = logging.getLogger(__name__) + + +def malware_detection_callback(file_path, status, error_info, **kwargs): + """Malware detection callback""" + + if status == ReportStatus.SAFE: + logger.info("File %s is safe", file_path) + # Get existing metadata + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + head_resp = s3_client.head_object(Bucket=bucket_name, Key=file_path) + metadata = head_resp.get("Metadata", {}) + metadata.update({"status": DocumentAttachmentStatus.READY}) + # Update status in metadata + s3_client.copy_object( + Bucket=bucket_name, + CopySource={"Bucket": bucket_name, "Key": file_path}, + Key=file_path, + ContentType=head_resp.get("ContentType"), + Metadata=metadata, + MetadataDirective="REPLACE", + ) + return + + document_id = kwargs.get("document_id") + logger.error( + "File %s for document %s is infected with malware. Error info: %s", + file_path, + document_id, + error_info, + ) + + # Remove the file from the document and change the status to unsafe + document = Document.objects.get(pk=document_id) + document.attachments.remove(file_path) + document.save(update_fields=["attachments"]) + + # Delete the file from the storage + default_storage.delete(file_path) diff --git a/src/backend/core/tests/test_malware_detection.py b/src/backend/core/tests/test_malware_detection.py new file mode 100644 index 000000000..cf2822f09 --- /dev/null +++ b/src/backend/core/tests/test_malware_detection.py @@ -0,0 +1,76 @@ +"""Test malware detection callback.""" + +import random + +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage + +import pytest +from lasuite.malware_detection.enums import ReportStatus + +from core.enums import DocumentAttachmentStatus +from core.factories import DocumentFactory +from core.malware_detection import malware_detection_callback + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def safe_file(): + """Create a safe file.""" + file_path = "test.txt" + default_storage.save(file_path, ContentFile("test")) + yield file_path + default_storage.delete(file_path) + + +@pytest.fixture +def unsafe_file(): + """Create an unsafe file.""" + file_path = "unsafe.txt" + default_storage.save(file_path, ContentFile("test")) + yield file_path + + +def test_malware_detection_callback_safe_status(safe_file): + """Test malware detection callback with safe status.""" + + document = DocumentFactory(attachments=[safe_file]) + + malware_detection_callback( + safe_file, + ReportStatus.SAFE, + error_info={}, + document_id=document.id, + ) + + document.refresh_from_db() + + assert safe_file in document.attachments + assert default_storage.exists(safe_file) + + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + head_resp = s3_client.head_object(Bucket=bucket_name, Key=safe_file) + metadata = head_resp.get("Metadata", {}) + assert metadata["status"] == DocumentAttachmentStatus.READY + + +def test_malware_detection_callback_unsafe_status(unsafe_file): + """Test malware detection callback with unsafe status.""" + + document = DocumentFactory(attachments=[unsafe_file]) + + malware_detection_callback( + unsafe_file, + random.choice( + [status.value for status in ReportStatus if status != ReportStatus.SAFE] + ), + error_info={"error": "test", "error_code": 4001}, + document_id=document.id, + ) + + document.refresh_from_db() + + assert unsafe_file not in document.attachments + assert not default_storage.exists(unsafe_file) diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index e49320814..c831c3bad 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -317,6 +317,7 @@ class Base(Configuration): "django.contrib.staticfiles", # OIDC third party "mozilla_django_oidc", + "lasuite.malware_detection", ] # Cache @@ -680,6 +681,21 @@ class Base(Configuration): }, } + MALWARE_DETECTION = { + "BACKEND": values.Value( + "lasuite.malware_detection.backends.dummy.DummyBackend", + environ_name="MALWARE_DETECTION_BACKEND", + environ_prefix=None, + ), + "PARAMETERS": values.DictValue( + default={ + "callback_path": "core.malware_detection.malware_detection_callback", + }, + environ_name="MALWARE_DETECTION_PARAMETERS", + environ_prefix=None, + ), + } + API_USERS_LIST_LIMIT = values.PositiveIntegerValue( default=5, environ_name="API_USERS_LIST_LIMIT", diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 89de2af26..49773f2e8 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "django-cors-headers==4.7.0", "django-countries==7.6.1", "django-filter==25.1", - "django-lasuite==0.0.7", + "django-lasuite[all]==0.0.8", "django-parler==2.3", "django-redis==5.4.0", "django-storages[s3]==1.14.6", From fa0091d2cf79553160f1d8de68688c08efe29484 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Mon, 5 May 2025 16:01:12 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8(backend)=20manage=20uploaded=20fi?= =?UTF-8?q?le=20status=20and=20call=20to=20malware=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the attachment_upload method, the status in the file metadata to processing and the malware_detection backend is called. We check in the media_auth if the status is ready in order to accept the request. --- CHANGELOG.md | 4 + src/backend/core/api/viewsets.py | 21 ++++- .../test_api_documents_attachment_upload.py | 49 ++++++++--- .../test_api_documents_media_auth.py | 82 ++++++++++++++++++- .../core/tests/test_malware_detection.py | 8 +- 5 files changed, 145 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f52a81dab..d11b9a7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(backend) integrate maleware_detection from django-lasuite + ## [3.2.0] - 2025-05-05 ## Added diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 544fdb2a9..7f2bb7bb0 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -24,6 +24,7 @@ import requests import rest_framework as drf from botocore.exceptions import ClientError +from lasuite.malware_detection import malware_detection from rest_framework import filters, status, viewsets from rest_framework import response as drf_response from rest_framework.permissions import AllowAny @@ -1156,7 +1157,10 @@ def attachment_upload(self, request, *args, **kwargs): # Prepare metadata for storage extra_args = { - "Metadata": {"owner": str(request.user.id)}, + "Metadata": { + "owner": str(request.user.id), + "status": enums.DocumentAttachmentStatus.PROCESSING, + }, "ContentType": serializer.validated_data["content_type"], } file_unsafe = "" @@ -1188,6 +1192,8 @@ def attachment_upload(self, request, *args, **kwargs): document.attachments.append(key) document.save() + malware_detection.analyse_file(key, document_id=document.id) + return drf.response.Response( {"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=drf.status.HTTP_201_CREATED, @@ -1271,6 +1277,19 @@ def media_auth(self, request, *args, **kwargs): logger.debug("User '%s' lacks permission for attachment", user) raise drf.exceptions.PermissionDenied() + # Check if the attachment is ready + s3_client = default_storage.connection.meta.client + bucket_name = default_storage.bucket_name + head_resp = s3_client.head_object(Bucket=bucket_name, Key=key) + metadata = head_resp.get("Metadata", {}) + # In order to be compatible with existing upload without `status` metadata, + # we consider them as ready. + if ( + metadata.get("status", enums.DocumentAttachmentStatus.READY) + != enums.DocumentAttachmentStatus.READY + ): + raise drf.exceptions.PermissionDenied() + # Generate S3 authorization headers using the extracted URL parameters request = utils.generate_s3_authorization_headers(key) diff --git a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py index a6324f24d..40f96bcb3 100644 --- a/src/backend/core/tests/documents/test_api_documents_attachment_upload.py +++ b/src/backend/core/tests/documents/test_api_documents_attachment_upload.py @@ -4,6 +4,7 @@ import re import uuid +from unittest import mock from django.core.files.storage import default_storage from django.core.files.uploadedfile import SimpleUploadedFile @@ -12,6 +13,7 @@ from rest_framework.test import APIClient from core import factories +from core.api.viewsets import malware_detection from core.tests.conftest import TEAM, USER, VIA pytestmark = pytest.mark.django_db @@ -59,7 +61,8 @@ def test_api_documents_attachment_upload_anonymous_success(): file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" - response = APIClient().post(url, {"file": file}, format="multipart") + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = APIClient().post(url, {"file": file}, format="multipart") assert response.status_code == 201 @@ -74,12 +77,13 @@ def test_api_documents_attachment_upload_anonymous_success(): assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"] # Now, check the metadata of the uploaded file - key = file_path.replace("/media", "") + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) file_head = default_storage.connection.meta.client.head_object( Bucket=default_storage.bucket_name, Key=key ) - assert file_head["Metadata"] == {"owner": "None"} + assert file_head["Metadata"] == {"owner": "None", "status": "processing"} assert file_head["ContentType"] == "image/png" assert file_head["ContentDisposition"] == 'inline; filename="test.png"' @@ -139,7 +143,8 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role): file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" - response = client.post(url, {"file": file}, format="multipart") + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") assert response.status_code == 201 @@ -147,6 +152,10 @@ def test_api_documents_attachment_upload_authenticated_success(reach, role): match = pattern.search(response.json()["file"]) file_id = match.group(1) + mock_analyse_file.assert_called_once_with( + f"{document.id!s}/attachments/{file_id!s}.png", document_id=document.id + ) + # Validate that file_id is a valid UUID uuid.UUID(file_id) @@ -210,7 +219,8 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams): file = SimpleUploadedFile(name="test.png", content=PIXEL, content_type="image/png") url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" - response = client.post(url, {"file": file}, format="multipart") + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") assert response.status_code == 201 @@ -226,11 +236,12 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams): assert document.attachments == [f"{document.id!s}/attachments/{file_id!s}.png"] # Now, check the metadata of the uploaded file - key = file_path.replace("/media", "") + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) file_head = default_storage.connection.meta.client.head_object( Bucket=default_storage.bucket_name, Key=key ) - assert file_head["Metadata"] == {"owner": str(user.id)} + assert file_head["Metadata"] == {"owner": str(user.id), "status": "processing"} assert file_head["ContentType"] == "image/png" assert file_head["ContentDisposition"] == 'inline; filename="test.png"' @@ -304,7 +315,8 @@ def test_api_documents_attachment_upload_fix_extension( url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/" file = SimpleUploadedFile(name=name, content=content) - response = client.post(url, {"file": file}, format="multipart") + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") assert response.status_code == 201 @@ -324,11 +336,16 @@ def test_api_documents_attachment_upload_fix_extension( uuid.UUID(file_id) # Now, check the metadata of the uploaded file - key = file_path.replace("/media", "") + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) file_head = default_storage.connection.meta.client.head_object( Bucket=default_storage.bucket_name, Key=key ) - assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["Metadata"] == { + "owner": str(user.id), + "is_unsafe": "true", + "status": "processing", + } assert file_head["ContentType"] == content_type assert file_head["ContentDisposition"] == f'attachment; filename="{name:s}"' @@ -364,7 +381,8 @@ def test_api_documents_attachment_upload_unsafe(): file = SimpleUploadedFile( name="script.exe", content=b"\x4d\x5a\x90\x00\x03\x00\x00\x00" ) - response = client.post(url, {"file": file}, format="multipart") + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(url, {"file": file}, format="multipart") assert response.status_code == 201 @@ -381,11 +399,16 @@ def test_api_documents_attachment_upload_unsafe(): file_id = file_id.replace("-unsafe", "") uuid.UUID(file_id) + key = file_path.replace("/media/", "") + mock_analyse_file.assert_called_once_with(key, document_id=document.id) # Now, check the metadata of the uploaded file - key = file_path.replace("/media", "") file_head = default_storage.connection.meta.client.head_object( Bucket=default_storage.bucket_name, Key=key ) - assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"} + assert file_head["Metadata"] == { + "owner": str(user.id), + "is_unsafe": "true", + "status": "processing", + } assert file_head["ContentType"] == "application/octet-stream" assert file_head["ContentDisposition"] == 'attachment; filename="script.exe"' diff --git a/src/backend/core/tests/documents/test_api_documents_media_auth.py b/src/backend/core/tests/documents/test_api_documents_media_auth.py index 13817e971..37f88daa3 100644 --- a/src/backend/core/tests/documents/test_api_documents_media_auth.py +++ b/src/backend/core/tests/documents/test_api_documents_media_auth.py @@ -15,6 +15,7 @@ from rest_framework.test import APIClient from core import factories, models +from core.enums import DocumentAttachmentStatus from core.tests.conftest import TEAM, USER, VIA pytestmark = pytest.mark.django_db @@ -45,6 +46,7 @@ def test_api_documents_media_auth_anonymous_public(): Key=key, Body=BytesIO(b"my prose"), ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, ) factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) @@ -93,7 +95,15 @@ def test_api_documents_media_auth_extensions(): keys = [] for ext in extensions: filename = f"{uuid4()!s}.{ext:s}" - keys.append(f"{document_id!s}/attachments/{filename:s}") + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, + ) + keys.append(key) factories.DocumentFactory(link_reach="public", attachments=keys) @@ -142,6 +152,7 @@ def test_api_documents_media_auth_anonymous_attachments(): Key=key, Body=BytesIO(b"my prose"), ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, ) factories.DocumentFactory(id=document_id, link_reach="restricted") @@ -205,6 +216,7 @@ def test_api_documents_media_auth_authenticated_public_or_authenticated(reach): Key=key, Body=BytesIO(b"my prose"), ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, ) factories.DocumentFactory(id=document_id, link_reach=reach, attachments=[key]) @@ -283,6 +295,7 @@ def test_api_documents_media_auth_related(via, mock_user_teams): Key=key, Body=BytesIO(b"my prose"), ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.READY}, ) document = factories.DocumentFactory( @@ -321,3 +334,70 @@ def test_api_documents_media_auth_related(via, mock_user_teams): timeout=1, ) assert response.content.decode("utf-8") == "my prose" + + +def test_api_documents_media_auth_not_ready_status(): + """Attachments with status not ready should not be accessible""" + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + Metadata={"status": DocumentAttachmentStatus.PROCESSING}, + ) + + factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) + + original_url = f"http://localhost/media/{key:s}" + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 403 + + +def test_api_documents_media_auth_missing_status_metadata(): + """Attachments without status metadata should be considered as ready""" + document_id = uuid4() + filename = f"{uuid4()!s}.jpg" + key = f"{document_id!s}/attachments/{filename:s}" + default_storage.connection.meta.client.put_object( + Bucket=default_storage.bucket_name, + Key=key, + Body=BytesIO(b"my prose"), + ContentType="text/plain", + ) + + factories.DocumentFactory(id=document_id, link_reach="public", attachments=[key]) + + original_url = f"http://localhost/media/{key:s}" + response = APIClient().get( + "/api/v1.0/documents/media-auth/", HTTP_X_ORIGINAL_URL=original_url + ) + + assert response.status_code == 200 + + authorization = response["Authorization"] + assert "AWS4-HMAC-SHA256 Credential=" in authorization + assert ( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=" + in authorization + ) + assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ") + + s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL) + file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}" + response = requests.get( + file_url, + headers={ + "authorization": authorization, + "x-amz-date": response["x-amz-date"], + "x-amz-content-sha256": response["x-amz-content-sha256"], + "Host": f"{s3_url.hostname:s}:{s3_url.port:d}", + }, + timeout=1, + ) + assert response.content.decode("utf-8") == "my prose" diff --git a/src/backend/core/tests/test_malware_detection.py b/src/backend/core/tests/test_malware_detection.py index cf2822f09..57da7643d 100644 --- a/src/backend/core/tests/test_malware_detection.py +++ b/src/backend/core/tests/test_malware_detection.py @@ -15,8 +15,8 @@ pytestmark = pytest.mark.django_db -@pytest.fixture -def safe_file(): +@pytest.fixture(name="safe_file") +def fixture_safe_file(): """Create a safe file.""" file_path = "test.txt" default_storage.save(file_path, ContentFile("test")) @@ -24,8 +24,8 @@ def safe_file(): default_storage.delete(file_path) -@pytest.fixture -def unsafe_file(): +@pytest.fixture(name="unsafe_file") +def fixture_unsafe_file(): """Create an unsafe file.""" file_path = "unsafe.txt" default_storage.save(file_path, ContentFile("test"))