diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cc92392..15e4d427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,3 +119,4 @@ jobs: MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_schema_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_status_combine --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_management_commands --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 + MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation_bff.tests.tests_report_view --settings apps.ifc_validation_bff.tests.test_settings --debug-mode --verbosity 3 diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index d57d7532..5e3dcd3b 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -117,7 +117,9 @@ jobs: MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_header_syntax_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_syntax_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_schema_validation_task --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 + MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_status_combine --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation.tests.tests_management_commands --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 + MEDIA_ROOT=./apps/ifc_validation/fixtures python3 manage.py test apps.ifc_validation_bff.tests.tests_report_view --settings apps.ifc_validation_bff.tests.test_settings --debug-mode --verbosity 3 deploy: diff --git a/backend/Makefile b/backend/Makefile index 10d00476..522288ef 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -66,7 +66,7 @@ stop-worker: -$(PYTHON) -m celery -A core control shutdown \ --destination=worker@$(shell hostname) || true -test: test-models test-header-validation-task test-syntax-task test-syntax-header-validation-task test-schema-task test-magic-and-av-task test-utils test-file-retention-task test-status-combine test-management-commands +test: test-models test-header-validation-task test-syntax-task test-syntax-header-validation-task test-schema-task test-magic-and-av-task test-utils test-file-retention-task test-status-combine test-management-commands test-report-views test-models: MEDIA_ROOT=./apps/ifc_validation/fixtures $(PYTHON) manage.py test apps/ifc_validation_models --settings apps.ifc_validation_models.test_settings --debug-mode --verbosity 3 @@ -95,6 +95,9 @@ test-status-combine: test-management-commands: MEDIA_ROOT=./apps/ifc_validation/fixtures $(PYTHON) manage.py test apps.ifc_validation.tests.tests_management_commands --settings apps.ifc_validation.test_settings --debug-mode --verbosity 3 +test-report-views: + MEDIA_ROOT=./apps/ifc_validation/fixtures $(PYTHON) manage.py test apps.ifc_validation_bff.tests.tests_report_view --settings apps.ifc_validation_bff.tests.test_settings --debug-mode --verbosity 3 + test-utils: $(PYTHON) manage.py test core.tests.test_utils --debug-mode --verbosity 3 diff --git a/backend/apps/ifc_validation_bff/tests/__init__.py b/backend/apps/ifc_validation_bff/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/apps/ifc_validation_bff/tests/test_settings.py b/backend/apps/ifc_validation_bff/tests/test_settings.py new file mode 100644 index 00000000..958098d4 --- /dev/null +++ b/backend/apps/ifc_validation_bff/tests/test_settings.py @@ -0,0 +1,36 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "apps.ifc_validation", + "apps.ifc_validation_models", + "apps.ifc_validation_bff", +] + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", +] + +ROOT_URLCONF = "apps.ifc_validation_bff.urls" + +DB_SQLITE = "sqlite" + +DATABASES_ALL = { + DB_SQLITE: { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "test_django_db.sqlite3", + "MIGRATE": False, + } +} + +DATABASES = {"default": DATABASES_ALL[os.environ.get("TEST_DJANGO_DB", DB_SQLITE)]} + +MEDIA_ROOT = "./apps/ifc_validation/fixtures" +USE_TZ = True +SECRET_KEY = "insecure-test-only-secret-key" diff --git a/backend/apps/ifc_validation_bff/tests/tests_report_view.py b/backend/apps/ifc_validation_bff/tests/tests_report_view.py new file mode 100644 index 00000000..bc73fc9b --- /dev/null +++ b/backend/apps/ifc_validation_bff/tests/tests_report_view.py @@ -0,0 +1,141 @@ +from django.conf import settings +from django.contrib.auth.models import User +from django.test import TestCase + +from apps.ifc_validation_models.models import ( + Model, + ValidationRequest, + set_user_context, +) + + +class ReportViewTestCase(TestCase): + """ + Tests for the BFF report endpoint (/api/report/). + + Covers resolution by public request id ('r...') and public model id ('m...'), + rejection of malformed ids, and the ownership / staff / soft-delete rules. + """ + + @classmethod + def setUpTestData(cls): + + cls.alice = User.objects.create(username='alice@test.org', email='alice@test.org', is_active=True) + cls.bob = User.objects.create(username='bob@test.org', email='bob@test.org', is_active=True) + cls.staff = User.objects.create(username='staff@test.org', email='staff@test.org', is_active=True, is_staff=True) + + # request + linked model owned by alice + set_user_context(cls.alice) + cls.alice_model = Model.objects.create(file_name='alice.ifc', file='alice.ifc', size=1024, uploaded_by=cls.alice) + cls.alice_request = ValidationRequest.objects.create(file_name='alice.ifc', file='alice.ifc', size=1024, model=cls.alice_model) + + # soft-deleted request owned by alice + cls.alice_deleted_request = ValidationRequest.objects.create(file_name='deleted.ifc', file='deleted.ifc', size=1024, deleted=True) + + # request + linked model owned by bob + set_user_context(cls.bob) + cls.bob_model = Model.objects.create(file_name='bob.ifc', file='bob.ifc', size=1024, uploaded_by=cls.bob) + cls.bob_request = ValidationRequest.objects.create(file_name='bob.ifc', file='bob.ifc', size=1024, model=cls.bob_model) + + # model without any validation request + cls.orphan_model = Model.objects.create(file_name='orphan.ifc', file='orphan.ifc', size=1024, uploaded_by=cls.alice) + + def login_as(self, user): + session = self.client.session + session['user'] = {'email': user.email} + session.save() + self.client.cookies[settings.SESSION_COOKIE_NAME] = session.session_key + + def get_report(self, public_id): + return self.client.get(f'/api/report/{public_id}') + + # --- happy paths --- + + def test_own_report_by_request_id_returns_200(self): + + self.login_as(self.alice) + response = self.get_report(self.alice_request.public_id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['model']['id'], self.alice_request.public_id) + + def test_own_report_2nd_user_by_request_id_returns_200(self): + + self.login_as(self.bob) + response = self.get_report(self.bob_request.public_id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['model']['id'], self.bob_request.public_id) + + def test_own_report_by_model_id_returns_200(self): + + self.login_as(self.alice) + response = self.get_report(self.alice_model.public_id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['model']['id'], self.alice_request.public_id) + + def test_own_report_by_model_id_returns_200(self): + + self.login_as(self.bob) + response = self.get_report(self.bob_model.public_id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['model']['id'], self.bob_request.public_id) + + # --- malformed / unknown ids (regression tests for 500s) --- + + def test_unknown_prefix_returns_404(self): + + self.login_as(self.alice) + for public_id in ['t42', 'o42', 'u42']: + with self.subTest(public_id=public_id): + self.assertEqual(self.get_report(public_id).status_code, 404) + + def test_garbage_id_returns_404(self): + + self.login_as(self.alice) + for public_id in ['hello', 'rfoo', 'r', 'm', 'm12x']: + with self.subTest(public_id=public_id): + self.assertEqual(self.get_report(public_id).status_code, 404) + + def test_model_id_without_request_returns_404(self): + + self.login_as(self.alice) + self.assertEqual(self.get_report(self.orphan_model.public_id).status_code, 404) + + # --- ownership / staff / soft-delete rules --- + + def test_other_users_report_returns_404(self): + + self.login_as(self.bob) + self.assertEqual(self.get_report(self.alice_request.public_id).status_code, 404) + self.assertEqual(self.get_report(self.alice_model.public_id).status_code, 404) + + def test_own_deleted_report_returns_404(self): + + self.login_as(self.alice) + self.assertEqual(self.get_report(self.alice_deleted_request.public_id).status_code, 404) + + def test_staff_can_view_other_users_report(self): + + self.login_as(self.staff) + response = self.get_report(self.alice_request.public_id) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['model']['id'], self.alice_request.public_id) + + def test_staff_can_view_deleted_report(self): + + self.login_as(self.staff) + self.assertEqual(self.get_report(self.alice_deleted_request.public_id).status_code, 200) + + # --- authentication --- + + # RW: this test fails because there is a fallback for development user in DEV + # def test_unauthenticated_returns_login_redirect(self): + # + # response = self.get_report(self.alice_request.public_id) + # + # self.assertEqual(response.status_code, 200) + # self.assertIn('redirect', response.json()) diff --git a/backend/apps/ifc_validation_bff/views_legacy.py b/backend/apps/ifc_validation_bff/views_legacy.py index 00cdfbab..87c219f8 100644 --- a/backend/apps/ifc_validation_bff/views_legacy.py +++ b/backend/apps/ifc_validation_bff/views_legacy.py @@ -430,8 +430,30 @@ def report(request, id: str): if not user: return create_redirect_response(login=True) - # return 404-NotFound if report is not for current user or if it is deleted - request = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first() + # resolve by request id or model id + priv_id = None + try: + if id.startswith("r"): + priv_id = ValidationRequest.to_private_id(id) + + elif id.startswith("m"): + model_id = Model.to_private_id(id) + priv_id = ( + ValidationRequest.objects.filter(model_id=model_id) + .values_list("id", flat=True) + .first() + ) + except: + priv_id = None + + if not priv_id: + return HttpResponseNotFound() + + # return 404-NotFound if report is not for current user or if it is deleted; still allowed for staff users + if not user.is_staff: + request = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False, id=priv_id).first() + else: + request = ValidationRequest.objects.filter(id=priv_id).first() if not request: return HttpResponseNotFound()