From d91c83fd050522bf1d527a3480116ca4e2f6362a Mon Sep 17 00:00:00 2001 From: lullah Date: Tue, 28 Jul 2026 16:11:24 +0100 Subject: [PATCH 1/2] make created setable on upload #415 --- stixify/web/models.py | 3 +- stixify/web/serializers.py | 1 + stixify/web/views.py | 6 ++-- tests/conftest.py | 17 +++++----- tests/src/views/test_file_view.py | 50 ++++++++++++++++++++++++++++- tests/src/views/test_report_view.py | 23 ++++++++++--- 6 files changed, 83 insertions(+), 17 deletions(-) diff --git a/stixify/web/models.py b/stixify/web/models.py index efecd93..49f0786 100644 --- a/stixify/web/models.py +++ b/stixify/web/models.py @@ -14,6 +14,7 @@ import txt2stix, txt2stix.extractions from django.core.exceptions import ValidationError from datetime import UTC, datetime, timezone +from django.utils import timezone as dj_timezone from django.core.files.uploadedfile import InMemoryUploadedFile import stix2 from file2txt.parsers.core import BaseParser @@ -76,7 +77,7 @@ class CommonSTIXProps(models.Model): labels = ArrayField(base_field=models.CharField(max_length=256), default=list, help_text="These will be added to the `labels` property of the STIX Report object generated") identity = models.JSONField(default=default_identity, validators=[validate_identity], help_text="""This is a full STIX Identity JSON. e.g. `{"type":"identity","spec_version":"2.1","id":"identity--b1ae1a15-6f4b-431e-b990-1b9678f35e15","name":"Dummy Identity"}`. If no value is passed, [the Stixify identity object will be used](https://raw.githubusercontent.com/muchdogesec/stix4doge/refs/heads/main/objects/identity/stixify.json).""") - created = models.DateTimeField(auto_now_add=True) + created = models.DateTimeField(default=dj_timezone.now, help_text="This will be used as the `created` time of the STIX Report object generated. Defaults to the current time if not passed. This cannot be changed once the File has been created.") modified = models.DateTimeField(auto_now=True) class Meta: diff --git a/stixify/web/serializers.py b/stixify/web/serializers.py index dc30899..1c0019e 100644 --- a/stixify/web/serializers.py +++ b/stixify/web/serializers.py @@ -118,6 +118,7 @@ class FileSerializer(serializers.ModelSerializer): max_value=100, help_text="A value between `0`-`100`. This value is determined by the content check module of the profile used to process the file. If a confidence value is set on the File object, that value will be used instead.", ) + created = serializers.DateTimeField(required=False, help_text="Set the `created` time of the STIX Report object generated for this File. If not passed, the time the File was uploaded will be used. This cannot be changed once the File has been created.") class Meta: model = File diff --git a/stixify/web/views.py b/stixify/web/views.py index 8d409c6..78b1ed1 100644 --- a/stixify/web/views.py +++ b/stixify/web/views.py @@ -307,7 +307,7 @@ def partial_update(self, request, *args, **kwargs): ) serializer.is_valid(raise_exception=True) serializer.save() - ReportView.update_report(file_obj.report_id, serializer.validated_data) + ReportView.update_report(file_obj.report_id, serializer.validated_data, file_obj.modified) return Response(FileSerializer(file_obj, context={"request": request}).data) @extend_schema( @@ -764,7 +764,7 @@ def validate_report_id(self, report_id: str): return report_uuid @classmethod - def update_report(cls, report_id, validated_data): + def update_report(cls, report_id, validated_data, modified=None): report = cls.get_report(report_id).data for k in ["name", "labels"]: if k not in validated_data: @@ -779,7 +779,7 @@ def update_report(cls, report_id, validated_data): url=source, ) ) - report["modified"] = stix2_format_datetime(timezone.now()) + report["modified"] = stix2_format_datetime(modified or timezone.now()) helper = ArangoDBHelper(settings.VIEW_NAME, None) returned = helper.execute_query( """ diff --git a/tests/conftest.py b/tests/conftest.py index 0f95228..78cd56e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,15 +56,16 @@ def stixifier_profile(): @pytest.fixture def identity(): from dogesec_commons.identity.models import Identity - - identity = Identity.objects.create( + identity, _ = Identity.objects.get_or_create( id="identity--c5f27ca2-a580-4fee-9bb9-753e2b563a30", - created=timezone.now(), - modified=timezone.now(), - stix=dict( - name="dummy identity", - identity_class="individual", - created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5", + defaults=dict( + created=timezone.now(), + modified=timezone.now(), + stix=dict( + name="dummy identity", + identity_class="individual", + created_by_ref="identity--9779a2db-f98c-5f4b-8d08-8ee04e02dbb5", + ), ), ) yield identity diff --git a/tests/src/views/test_file_view.py b/tests/src/views/test_file_view.py index f8de2b9..3e9cef4 100644 --- a/tests/src/views/test_file_view.py +++ b/tests/src/views/test_file_view.py @@ -41,6 +41,54 @@ def test_create(client, stixifier_profile, api_schema, identity): api_schema['/api/v1/files/']['POST'].validate_response(Transport.get_st_response(resp)) +@pytest.mark.django_db +def test_create_with_custom_created(client, stixifier_profile, api_schema, identity): + custom_created = "2020-05-17T12:30:00Z" + payload = dict( + file=SimpleUploadedFile(name="name.pdf", content=b"file content"), + profile_id=stixifier_profile.id, + identity_id=identity.id, + mode="md", + name="Upload test with custom created", + report_id="report--567681d6-2817-4d84-84fb-87b2f059b92e", + created=custom_created, + ) + with ( + patch( + "stixify.web.views.JobSerializer", side_effect=JobSerializer + ) as mock_job_serializer_cls, + patch("stixify.web.views.new_task") as mock_new_task, + ): + resp = client.post("/api/v1/files/", data=payload) + assert resp.status_code == 201, resp.content + file = models.File.objects.get(pk="567681d6-2817-4d84-84fb-87b2f059b92e") + assert file.created.isoformat() == "2020-05-17T12:30:00+00:00" + resp.wsgi_request.FILES.clear() + api_schema['/api/v1/files/']['POST'].validate_response(Transport.get_st_response(resp)) + + +@pytest.mark.django_db +def test_patch_file_cannot_change_created(client, stixify_file, api_schema): + original_created = stixify_file.created + payload = { + "name": "Updated name only", + "created": "1999-01-01T00:00:00Z", + } + with patch("stixify.web.views.ReportView.update_report"): + resp = client.patch( + "/api/v1/files/dcbeb240-8dd6-4892-8e9e-7b6bda30e454/", + data=json.dumps(payload), + content_type="application/json", + ) + + assert resp.status_code == 200, resp.content + file_obj = models.File.objects.get(pk="dcbeb240-8dd6-4892-8e9e-7b6bda30e454") + assert file_obj.created == original_created, "created must not be changed via PATCH" + api_schema['/api/v1/files/{file_id}/']['PATCH'].validate_response( + Transport.get_st_response(resp) + ) + + @pytest.mark.django_db def test_create_mhtml_pdf(client, stixifier_profile, api_schema, identity): payload = dict( @@ -206,7 +254,7 @@ def test_patch_file_metadata(client, stixify_file, api_schema): assert file_obj.name == payload["name"] assert file_obj.labels == payload["labels"] assert file_obj.sources == payload["sources"] - mock_update_report.assert_called_once_with("report--dcbeb240-8dd6-4892-8e9e-7b6bda30e454", {'name': 'Updated file name', 'labels': ['threat-report', 'customer-facing'], 'sources': ['https://example.com/report', 'https://example.com/notes']}) + mock_update_report.assert_called_once_with("report--dcbeb240-8dd6-4892-8e9e-7b6bda30e454", {'name': 'Updated file name', 'labels': ['threat-report', 'customer-facing'], 'sources': ['https://example.com/report', 'https://example.com/notes']}, file_obj.modified) api_schema['/api/v1/files/{file_id}/']['PATCH'].validate_response( Transport.get_st_response(resp) ) diff --git a/tests/src/views/test_report_view.py b/tests/src/views/test_report_view.py index 06eeca9..c1a82c6 100644 --- a/tests/src/views/test_report_view.py +++ b/tests/src/views/test_report_view.py @@ -2,6 +2,8 @@ import typing import uuid +from datetime import datetime, timezone as dt_timezone +from stix2.utils import format_datetime as stix2_format_datetime from stixify.classifier.models import Cluster, DocumentEmbedding from stixify.web import models from stixify.web.serializers import FileSerializer, JobSerializer @@ -120,8 +122,15 @@ def test_list(client, api_schema): api_schema['/api/v1/reports/']['GET'].validate_response(Transport.get_st_response(resp)) +@pytest.mark.parametrize( + 'modified_time', + [ + None, + datetime(2025, 1, 1, 12, 0, 0, tzinfo=dt_timezone.utc), + ], +) @pytest.mark.django_db -def test_update_report_updates_name_labels_and_sources(client, api_schema): +def test_update_report_updates_name_labels_and_sources(client, api_schema, modified_time): report_id = "report--52d2146c-798a-440f-942f-6fe039fb8995" original_resp = client.get(f"/api/v1/reports/{report_id}/") assert original_resp.status_code == 200, original_resp.content @@ -146,14 +155,20 @@ def test_update_report_updates_name_labels_and_sources(client, api_schema): } try: - assert ReportView.update_report(report_id, payload) is True + assert ReportView.update_report(report_id, payload, modified=modified_time) is True time.sleep(1) # wait for it to update view resp = client.get(f"/api/v1/reports/{report_id}/") assert resp.status_code == 200, resp.content assert resp.data["name"] == payload["name"] + assert resp.data["created"] == original_resp.data["created"], "Report created timestamp should not change" assert resp.data["labels"] == payload["labels"] - assert resp.data["modified"] > original_resp.data["modified"], "Report modified timestamp should be updated" + if modified_time: + assert resp.data["modified"] == stix2_format_datetime(modified_time), ( + "Report modified timestamp should match the passed-in modified value, not now()" + ) + else: + assert resp.data["modified"] > original_resp.data["modified"], "Report modified timestamp should be updated" sources = [ ref["url"] @@ -167,7 +182,7 @@ def test_update_report_updates_name_labels_and_sources(client, api_schema): ) finally: ReportView.update_report(report_id, original_payload) - + time.sleep(1) # wait for it to update view before other tests run @pytest.mark.parametrize( "report_id,expected_ids", From 60fd132e906d269a40c8835316432e4cff273b3c Mon Sep 17 00:00:00 2001 From: lullah Date: Tue, 28 Jul 2026 16:13:02 +0100 Subject: [PATCH 2/2] add migration file --- .../web/migrations/0025_alter_file_created.py | 19 +++++++++++++++++++ stixify/web/models.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 stixify/web/migrations/0025_alter_file_created.py diff --git a/stixify/web/migrations/0025_alter_file_created.py b/stixify/web/migrations/0025_alter_file_created.py new file mode 100644 index 0000000..b9d4d4e --- /dev/null +++ b/stixify/web/migrations/0025_alter_file_created.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.12 on 2026-07-28 14:40 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("stixify_core", "0024_file_admiralty_information_credibility_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="file", + name="created", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/stixify/web/models.py b/stixify/web/models.py index 49f0786..0cdc3b9 100644 --- a/stixify/web/models.py +++ b/stixify/web/models.py @@ -77,7 +77,7 @@ class CommonSTIXProps(models.Model): labels = ArrayField(base_field=models.CharField(max_length=256), default=list, help_text="These will be added to the `labels` property of the STIX Report object generated") identity = models.JSONField(default=default_identity, validators=[validate_identity], help_text="""This is a full STIX Identity JSON. e.g. `{"type":"identity","spec_version":"2.1","id":"identity--b1ae1a15-6f4b-431e-b990-1b9678f35e15","name":"Dummy Identity"}`. If no value is passed, [the Stixify identity object will be used](https://raw.githubusercontent.com/muchdogesec/stix4doge/refs/heads/main/objects/identity/stixify.json).""") - created = models.DateTimeField(default=dj_timezone.now, help_text="This will be used as the `created` time of the STIX Report object generated. Defaults to the current time if not passed. This cannot be changed once the File has been created.") + created = models.DateTimeField(default=dj_timezone.now) modified = models.DateTimeField(auto_now=True) class Meta: