diff --git a/backend/tests/apps/ai/agent/nodes_test.py b/backend/tests/apps/ai/agent/nodes_test.py new file mode 100644 index 0000000000..4d5cc5210e --- /dev/null +++ b/backend/tests/apps/ai/agent/nodes_test.py @@ -0,0 +1,129 @@ +import openai +import pytest + +from apps.ai.agent.nodes import AgentNodes +from apps.ai.common.constants import DEFAULT_CHUNKS_RETRIEVAL_LIMIT, DEFAULT_SIMILARITY_THRESHOLD + + +class TestAgentNodes: + @pytest.fixture + def mock_openai(self, mocker): + mocker.patch("os.getenv", return_value="fake-key") + return mocker.patch("apps.ai.agent.nodes.openai.OpenAI") + + @pytest.fixture + def nodes(self, mock_openai, mocker): + mocker.patch("apps.ai.agent.nodes.Retriever") + mocker.patch("apps.ai.agent.nodes.Generator") + return AgentNodes() + + def test_init_raises_error_without_api_key(self, mocker): + mocker.patch("os.getenv", return_value=None) + with pytest.raises( + ValueError, match="DJANGO_OPEN_AI_SECRET_KEY environment variable not set" + ): + AgentNodes() + + def test_retrieve_logic(self, nodes, mocker): + state = {"query": "test query"} + + mock_metadata = {"entity_types": ["code"], "filters": {}, "requested_fields": []} + nodes.extract_query_metadata = mocker.Mock(return_value=mock_metadata) + + nodes.retriever.retrieve.return_value = [{"text": "chunk1", "similarity": 0.9}] + nodes.filter_chunks_by_metadata = mocker.Mock( + return_value=[{"text": "chunk1", "similarity": 0.9}] + ) + + new_state = nodes.retrieve(state) + + assert "context_chunks" in new_state + assert len(new_state["context_chunks"]) == 1 + assert new_state["extracted_metadata"] == mock_metadata + + nodes.retriever.retrieve.assert_called_with( + query="test query", + limit=DEFAULT_CHUNKS_RETRIEVAL_LIMIT, + similarity_threshold=DEFAULT_SIMILARITY_THRESHOLD, + content_types=["code"], + ) + + def test_retrieve_skips_if_chunks_present(self, nodes): + state = {"context_chunks": ["existing"]} + new_state = nodes.retrieve(state) + assert new_state == state + + def test_generate_logic(self, nodes): + state = {"query": "test query", "context_chunks": []} + nodes.generator.generate_answer.return_value = "Generated answer" + + new_state = nodes.generate(state) + + assert new_state["answer"] == "Generated answer" + assert new_state["iteration"] == 1 + assert len(new_state["history"]) == 1 + assert new_state["history"][0]["answer"] == "Generated answer" + + def test_evaluate_requires_more_context(self, nodes, mocker): + state = {"query": "test", "answer": "unsure", "extracted_metadata": {}} + + mock_eval = {"requires_more_context": True, "feedback": "need more info"} + nodes.call_evaluator = mocker.Mock(return_value=mock_eval) + + nodes.retriever.retrieve.return_value = ["new_chunk"] + nodes.filter_chunks_by_metadata = mocker.Mock(return_value=["new_chunk"]) + + new_state = nodes.evaluate(state) + + assert new_state["feedback"] == "Expand and refine answer using newly retrieved context." + assert "context_chunks" in new_state + assert new_state["evaluation"] == mock_eval + + def test_evaluate_complete(self, nodes, mocker): + state = {"query": "test", "answer": "good"} + mock_eval = {"requires_more_context": False, "feedback": None, "complete": True} + nodes.call_evaluator = mocker.Mock(return_value=mock_eval) + + new_state = nodes.evaluate(state) + assert new_state["feedback"] is None + assert new_state["evaluation"] == mock_eval + + def test_route_from_evaluation(self, nodes): + assert nodes.route_from_evaluation({"evaluation": {"complete": True}}) == "complete" + assert ( + nodes.route_from_evaluation({"evaluation": {"complete": False}, "iteration": 0}) + == "refine" + ) + assert ( + nodes.route_from_evaluation({"evaluation": {"complete": False}, "iteration": 100}) + == "complete" + ) + + def test_filter_chunks_by_metadata(self, nodes): + chunks = [ + {"text": "foo", "additional_context": {"lang": "python"}, "similarity": 0.8}, + {"text": "bar", "additional_context": {"lang": "go"}, "similarity": 0.9}, + ] + metadata = {"filters": {"lang": "python"}, "requested_fields": []} + + filtered = nodes.filter_chunks_by_metadata(chunks, metadata, limit=10) + assert filtered[0]["text"] == "foo" + + def test_extract_query_metadata_openai_error(self, nodes, mocker): + mocker.patch( + "apps.ai.agent.nodes.Prompt.get_metadata_extractor_prompt", return_value="sys prompt" + ) + nodes.openai_client.chat.completions.create.side_effect = openai.OpenAIError("Error") + + metadata = nodes.extract_query_metadata("query") + assert metadata["intent"] == "general query" + + def test_call_evaluator_openai_error(self, nodes, mocker): + nodes.generator.prepare_context.return_value = "ctx" + mocker.patch( + "apps.ai.agent.nodes.Prompt.get_evaluator_system_prompt", return_value="sys prompt" + ) + nodes.openai_client.chat.completions.create.side_effect = openai.OpenAIError("Error") + + eval_result = nodes.call_evaluator(query="q", answer="a", context_chunks=[]) + assert eval_result["feedback"] == "Evaluator error or invalid response." diff --git a/backend/tests/apps/common/middlewares/__init__.py b/backend/tests/apps/common/middlewares/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/common/middlewares/block_null_characters_test.py b/backend/tests/apps/common/middlewares/block_null_characters_test.py new file mode 100644 index 0000000000..63fc47e881 --- /dev/null +++ b/backend/tests/apps/common/middlewares/block_null_characters_test.py @@ -0,0 +1,69 @@ +import json +from http import HTTPStatus + +import pytest +from django.http import HttpResponse +from django.test import RequestFactory + +from apps.common.middlewares.block_null_characters import BlockNullCharactersMiddleware + + +class TestBlockNullCharactersMiddleware: + @pytest.fixture + def middleware(self): + def get_response(_request): + return HttpResponse("OK") + + return BlockNullCharactersMiddleware(get_response) + + @pytest.fixture + def factory(self): + return RequestFactory() + + def test_clean_request_passes(self, middleware, factory): + request = factory.get("/clean/path") + response = middleware(request) + assert response.status_code == HTTPStatus.OK + assert response.content == b"OK" + + def test_null_in_path_blocks(self, middleware, factory): + request = factory.get("/path/with/\x00/null") + response = middleware(request) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert json.loads(response.content) == { + "message": "Request contains null characters in URL or parameters " + "which are not allowed.", + "errors": {}, + } + + def test_null_in_query_params_blocks(self, middleware, factory): + request = factory.get("/clean/path", {"q": "bad\x00value"}) + response = middleware(request) + assert response.status_code == HTTPStatus.BAD_REQUEST + + def test_null_in_post_data_blocks(self, middleware, factory): + request = factory.post("/clean/path", {"data": "bad\x00value"}) + response = middleware(request) + assert response.status_code == HTTPStatus.BAD_REQUEST + + def test_null_in_body_blocks(self, middleware, factory): + request = factory.post( + "/clean/path", + data=b'{"key": "bad\x00value"}', + content_type="application/json", + ) + response = middleware(request) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert json.loads(response.content) == { + "message": "Request contains null characters in body which are not allowed.", + "errors": {}, + } + + def test_unicode_null_in_body_blocks(self, middleware, factory): + request = factory.post( + "/clean/path", + data=b'{"key": "bad\\u0000value"}', + content_type="application/json", + ) + response = middleware(request) + assert response.status_code == HTTPStatus.BAD_REQUEST diff --git a/backend/tests/apps/github/management/commands/github_update_pull_requests_test.py b/backend/tests/apps/github/management/commands/github_update_pull_requests_test.py new file mode 100644 index 0000000000..6d1a2f0658 --- /dev/null +++ b/backend/tests/apps/github/management/commands/github_update_pull_requests_test.py @@ -0,0 +1,80 @@ +from apps.github.management.commands.github_update_pull_requests import Command + + +class TestGithubUpdatePullRequests: + def test_handle_links_issues(self, mocker): + mock_repo = mocker.Mock(name="Repository", id=1) + mock_repo.name = "test-repo" + + mock_issue = mocker.Mock(name="Issue", id=10, number=123) + mock_issue.repository = mock_repo + + mock_pr = mocker.Mock(name="PullRequest", id=100, number=456) + mock_pr.repository = mock_repo + mock_pr.body = "This closes #123" + mock_pr.related_issues = mocker.Mock() + mock_pr.related_issues.values_list.return_value = [] + + mock_pr_qs = mocker.Mock() + mock_pr_qs.select_related.return_value.all.return_value = [mock_pr] + + mocker.patch( + "apps.github.management.commands.github_update_pull_requests.PullRequest.objects", + mock_pr_qs, + ) + + mock_issue_qs = mocker.Mock() + mock_issue_qs.filter.return_value = [mock_issue] + mocker.patch( + "apps.github.management.commands.github_update_pull_requests.Issue.objects", + mock_issue_qs, + ) + + command = Command() + command.stdout = mocker.Mock() + command.handle() + + mock_issue_qs.filter.assert_called_with(repository=mock_repo, number__in={123}) + mock_pr.related_issues.add.assert_called_with(10) + + def test_handle_no_repo_skipped(self, mocker): + mock_pr = mocker.Mock(name="PullRequest", id=100, number=456) + mock_pr.repository = None + mock_pr.related_issues = mocker.Mock() + + mock_pr_qs = mocker.Mock() + mock_pr_qs.select_related.return_value.all.return_value = [mock_pr] + + mocker.patch( + "apps.github.management.commands.github_update_pull_requests.PullRequest.objects", + mock_pr_qs, + ) + + command = Command() + command.stdout = mocker.Mock() + command.handle() + + mock_pr.related_issues.add.assert_not_called() + + def test_handle_no_keywords(self, mocker): + mock_repo = mocker.Mock(name="Repository") + mock_pr = mocker.Mock(name="PullRequest", id=100, number=456) + mock_pr.repository = mock_repo + mock_pr.body = "Just a normal PR" + + mock_pr_qs = mocker.Mock() + mock_pr_qs.select_related.return_value.all.return_value = [mock_pr] + mocker.patch( + "apps.github.management.commands.github_update_pull_requests.PullRequest.objects", + mock_pr_qs, + ) + + mock_issue_objects = mocker.patch( + "apps.github.management.commands.github_update_pull_requests.Issue.objects" + ) + + command = Command() + command.stdout = mocker.Mock() + command.handle() + + mock_issue_objects.filter.assert_not_called() diff --git a/backend/tests/apps/github/models/comment_test.py b/backend/tests/apps/github/models/comment_test.py new file mode 100644 index 0000000000..34d4df44a9 --- /dev/null +++ b/backend/tests/apps/github/models/comment_test.py @@ -0,0 +1,77 @@ +from django.contrib.contenttypes.models import ContentType + +from apps.github.models.comment import Comment +from apps.github.models.user import User + + +class TestComment: + def test_from_github_populates_fields(self, mocker): + comment = Comment() + gh_comment = mocker.Mock() + gh_comment.body = "Test body" + gh_comment.created_at = "2023-01-01T00:00:00Z" + gh_comment.updated_at = "2023-01-02T00:00:00Z" + + mock_author = mocker.Mock(spec=User) + mock_author._state = mocker.Mock() + + comment.from_github(gh_comment, author=mock_author) + + assert comment.body == "Test body" + assert comment.created_at == "2023-01-01T00:00:00Z" + assert comment.updated_at == "2023-01-02T00:00:00Z" + assert comment.author == mock_author + + def test_update_data_creates_new(self, mocker): + mocker.patch( + "apps.github.models.comment.Comment.objects.get", side_effect=Comment.DoesNotExist + ) + mock_ct = ContentType(app_label="fake", model="fake") + mock_ct.id = 1 + mocker.patch( + "django.contrib.contenttypes.models.ContentType.objects.get_for_model", + return_value=mock_ct, + ) + + gh_comment = mocker.Mock() + gh_comment.id = 12345 + gh_comment.body = "New comment" + + mock_save = mocker.patch.object(Comment, "save") + + mock_content_object = mocker.Mock() + mock_content_object.pk = 999 + + comment = Comment.update_data( + gh_comment, author=None, content_object=mock_content_object, save=True + ) + + assert comment.github_id == 12345 + assert comment.object_id == 999 + assert comment.content_type == mock_ct + mock_save.assert_called_once() + + def test_update_data_updates_existing(self, mocker): + existing_comment = Comment(github_id=12345, body="Old body") + mocker.patch( + "apps.github.models.comment.Comment.objects.get", return_value=existing_comment + ) + mock_save = mocker.patch.object(Comment, "save") + + gh_comment = mocker.Mock() + gh_comment.id = 12345 + gh_comment.body = "Updated body" + + comment = Comment.update_data(gh_comment, save=True) + + assert comment.body == "Updated body" + assert comment.github_id == 12345 + mock_save.assert_called_once() + + def test_str_representation(self): + comment = Comment(body="A very long comment body that should be truncated", author=None) + long_body = "A" * 60 + comment.body = long_body + + assert str(comment).startswith("None - AAAAA") + assert len(str(comment)) <= 60 diff --git a/backend/tests/apps/owasp/admin/mixins_test.py b/backend/tests/apps/owasp/admin/mixins_test.py new file mode 100644 index 0000000000..1984219262 --- /dev/null +++ b/backend/tests/apps/owasp/admin/mixins_test.py @@ -0,0 +1,140 @@ +from django.contrib.admin import ModelAdmin + +from apps.owasp.admin.mixins import ( + BaseOwaspAdminMixin, + GenericEntityAdminMixin, + StandardOwaspAdminMixin, +) +from apps.owasp.models.project import Project + + +class TestBaseOwaspAdminMixin: + """Tests for BaseOwaspAdminMixin.""" + + class MockAdmin(BaseOwaspAdminMixin, ModelAdmin): + """Mock admin class for testing BaseOwaspAdminMixin.""" + + model = Project + + def test_get_base_list_display(self, mocker): + admin = self.MockAdmin(Project, mocker.Mock()) + display = admin.get_base_list_display("extra_field") + + assert "extra_field" in display + assert "created_at" in display + assert "updated_at" in display + assert "name" in display + + def test_get_base_search_fields(self, mocker): + admin = self.MockAdmin(Project, mocker.Mock()) + fields = admin.get_base_search_fields("extra_search") + + assert "name" in fields + assert "key" in fields + assert "extra_search" in fields + + +class TestGenericEntityAdminMixin: + """Tests for GenericEntityAdminMixin.""" + + class MockGenericAdmin(GenericEntityAdminMixin, ModelAdmin): + """Mock admin class for testing GenericEntityAdminMixin.""" + + model = Project + + def test_custom_field_owasp_url(self, mocker): + admin = self.MockGenericAdmin(Project, mocker.Mock()) + obj = mocker.Mock() + obj.key = "test-project" + + url = admin.custom_field_owasp_url(obj) + assert "href='https://owasp.org/test-project'" in url + assert "target='_blank'" in url + + def test_custom_field_owasp_url_empty(self, mocker): + admin = self.MockGenericAdmin(Project, mocker.Mock()) + obj = mocker.Mock() + obj.key = None + + assert admin.custom_field_owasp_url(obj) == "" + + def test_custom_field_github_urls_repositories(self, mocker): + admin = self.MockGenericAdmin(Project, mocker.Mock()) + obj = mocker.Mock() + + repo1 = mocker.Mock() + repo1.owner.login = "owasp" + repo1.key = "repo-1" + + repo2 = mocker.Mock() + repo2.owner.login = "owasp" + repo2.key = "repo-2" + + obj.repositories.all.return_value = [repo1, repo2] + + urls = admin.custom_field_github_urls(obj) + assert "href='https://github.com/owasp/repo-1'" in urls + assert "href='https://github.com/owasp/repo-2'" in urls + + def test_custom_field_github_urls_fallback(self, mocker): + admin = self.MockGenericAdmin(Project, mocker.Mock()) + + class MockObj: + pass + + obj = MockObj() + obj.owasp_repository = mocker.Mock() + obj.owasp_repository.owner.login = "owasp" + obj.owasp_repository.key = "main-repo" + + urls = admin.custom_field_github_urls(obj) + assert "href='https://github.com/owasp/main-repo'" in urls + + +class TestStandardOwaspAdminMixin: + """Tests for StandardOwaspAdminMixin.""" + + class MockStandardAdmin(StandardOwaspAdminMixin, ModelAdmin): + """Mock admin class for testing StandardOwaspAdminMixin.""" + + model = Project + + def test_get_common_config_with_list_display(self, mocker): + admin = self.MockStandardAdmin(Project, mocker.Mock()) + config = admin.get_common_config(extra_list_display=("field1", "field2")) + + assert "list_display" in config + assert "field1" in config["list_display"] + assert "field2" in config["list_display"] + + def test_get_common_config_with_search_fields(self, mocker): + admin = self.MockStandardAdmin(Project, mocker.Mock()) + config = admin.get_common_config(extra_search_fields=("search1",)) + + assert "search_fields" in config + assert "search1" in config["search_fields"] + + def test_get_common_config_with_list_filters(self, mocker): + admin = self.MockStandardAdmin(Project, mocker.Mock()) + config = admin.get_common_config(extra_list_filters=("filter1",)) + + assert "list_filter" in config + assert "filter1" in config["list_filter"] + + def test_get_common_config_empty(self, mocker): + admin = self.MockStandardAdmin(Project, mocker.Mock()) + config = admin.get_common_config() + + assert config == {} + + def test_get_common_config_all_options(self, mocker): + admin = self.MockStandardAdmin(Project, mocker.Mock()) + config = admin.get_common_config( + extra_list_display=("display1",), + extra_search_fields=("search1",), + extra_list_filters=("filter1",), + ) + + assert "list_display" in config + assert "search_fields" in config + assert "list_filter" in config diff --git a/backend/tests/apps/owasp/api/internal/views/__init__.py b/backend/tests/apps/owasp/api/internal/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/owasp/api/internal/views/permissions_test.py b/backend/tests/apps/owasp/api/internal/views/permissions_test.py new file mode 100644 index 0000000000..23bb33f214 --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/views/permissions_test.py @@ -0,0 +1,112 @@ +from http import HTTPStatus + +import pytest +from django.contrib.auth.models import AnonymousUser +from django.http import HttpResponse + +from apps.owasp.api.internal.views.permissions import ( + dashboard_access_required, + has_dashboard_permission, +) + + +class TestPermissions: + @pytest.fixture + def user_with_staff(self, mocker): + user = mocker.Mock() + user.is_authenticated = True + user.github_user.is_owasp_staff = True + return user + + @pytest.fixture + def user_without_staff(self, mocker): + user = mocker.Mock() + user.is_authenticated = True + user.github_user.is_owasp_staff = False + return user + + def test_has_dashboard_permission_e2e(self, mocker): + request = mocker.Mock() + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=True, + ) + assert has_dashboard_permission(request) + + def test_has_dashboard_permission_staff(self, mocker, user_with_staff): + request = mocker.Mock() + request.user = user_with_staff + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + assert has_dashboard_permission(request) + + def test_has_dashboard_permission_no_staff(self, mocker, user_without_staff): + request = mocker.Mock() + request.user = user_without_staff + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + assert not has_dashboard_permission(request) + + def test_has_dashboard_permission_anonymous(self, mocker): + request = mocker.Mock() + request.user = AnonymousUser() + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + assert not has_dashboard_permission(request) + + def test_dashboard_access_required_decorator_allow(self, mocker, user_with_staff): + @dashboard_access_required + def my_view(_request): + return HttpResponse("OK") + + request = mocker.Mock() + request.user = user_with_staff + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + + response = my_view(request) + assert response.status_code == HTTPStatus.OK + assert response.content == b"OK" + + def test_dashboard_access_required_decorator_deny(self, mocker, user_without_staff): + @dashboard_access_required + def my_view(_request): + return HttpResponse("OK") + + request = mocker.Mock() + request.user = user_without_staff + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + + response = my_view(request) + assert response.status_code == HTTPStatus.FORBIDDEN diff --git a/backend/tests/apps/owasp/api/internal/views/project_health_metrics_test.py b/backend/tests/apps/owasp/api/internal/views/project_health_metrics_test.py new file mode 100644 index 0000000000..c96625719a --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/views/project_health_metrics_test.py @@ -0,0 +1,154 @@ +import io +from http import HTTPStatus + +import pytest +from django.http import Http404 + +from apps.owasp.api.internal.views.project_health_metrics import ( + generate_overview_pdf, + generate_project_health_metrics_pdf, +) + + +class TestProjectHealthMetricsViews: + @pytest.fixture + def user_with_permission(self, mocker): + user = mocker.Mock() + user.is_authenticated = True + user.github_user.is_owasp_staff = True + return user + + def test_generate_overview_pdf(self, user_with_permission, mocker): + mock_stats = mocker.Mock() + mocker.patch( + "apps.owasp.models.project_health_metrics.ProjectHealthMetrics.get_stats", + return_value=mock_stats, + ) + + mocker.patch( + "apps.owasp.api.internal.views.project_health_metrics.generate_metrics_overview_pdf", + return_value=io.BytesIO(b"PDF CONTENT"), + ) + + request = mocker.Mock() + request.method = "GET" + request.user = user_with_permission + + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + + response = generate_overview_pdf(request) + + assert response.status_code == HTTPStatus.OK + content = b"".join(response.streaming_content) + assert b"PDF CONTENT" in content + assert response["Content-Type"] == "application/pdf" + + def test_generate_project_health_metrics_pdf_success(self, user_with_permission, mocker): + project_key = "juice-shop" + mock_project = mocker.Mock() + + mocker.patch( + "apps.owasp.models.project.Project.objects.filter", + return_value=mocker.Mock(first=lambda: mock_project), + ) + + mock_qs = mocker.Mock() + mock_qs.filter.return_value.first.return_value = mocker.Mock() + mocker.patch( + "apps.owasp.models.project_health_metrics.ProjectHealthMetrics.get_latest_health_metrics", + return_value=mock_qs, + ) + + mocker.patch( + "apps.owasp.api.internal.views.project_health_metrics.generate_latest_metrics_pdf", + return_value=io.BytesIO(b"PROJECT PDF"), + ) + + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + + request = mocker.Mock() + request.method = "GET" + request.user = user_with_permission + + response = generate_project_health_metrics_pdf(request, project_key) + + assert response.status_code == HTTPStatus.OK + content = b"".join(response.streaming_content) + assert b"PROJECT PDF" in content + assert ( + f'filename="{project_key}_health_metrics_report.pdf"' + in response["Content-Disposition"] + ) + + def test_generate_project_health_metrics_pdf_project_not_found( + self, user_with_permission, mocker + ): + mocker.patch( + "apps.owasp.models.project.Project.objects.filter", + return_value=mocker.Mock(first=lambda: None), + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + + request = mocker.Mock() + request.method = "GET" + request.user = user_with_permission + + with pytest.raises(Http404): + generate_project_health_metrics_pdf(request, "unknown") + + def test_generate_project_health_metrics_pdf_generation_fails( + self, user_with_permission, mocker + ): + mock_project = mocker.Mock() + mocker.patch( + "apps.owasp.models.project.Project.objects.filter", + return_value=mocker.Mock(first=lambda: mock_project), + ) + + mock_qs = mocker.Mock() + mock_qs.filter.return_value.first.return_value = mocker.Mock() + mocker.patch( + "apps.owasp.models.project_health_metrics.ProjectHealthMetrics.get_latest_health_metrics", + return_value=mock_qs, + ) + + mocker.patch( + "apps.owasp.api.internal.views.project_health_metrics.generate_latest_metrics_pdf", + return_value=None, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_E2E_ENVIRONMENT", + new=False, + ) + mocker.patch( + "apps.owasp.api.internal.views.permissions.settings.IS_FUZZ_ENVIRONMENT", + new=False, + ) + + request = mocker.Mock() + request.method = "GET" + request.user = user_with_permission + + with pytest.raises(Http404): + generate_project_health_metrics_pdf(request, "fail") diff --git a/backend/tests/apps/owasp/api/internal/views/urls_test.py b/backend/tests/apps/owasp/api/internal/views/urls_test.py new file mode 100644 index 0000000000..97170f123b --- /dev/null +++ b/backend/tests/apps/owasp/api/internal/views/urls_test.py @@ -0,0 +1,22 @@ +from django.test import SimpleTestCase, override_settings +from django.urls import resolve, reverse + +from apps.owasp.api.internal.views.project_health_metrics import ( + generate_overview_pdf, + generate_project_health_metrics_pdf, +) + + +@override_settings(ROOT_URLCONF="apps.owasp.api.internal.views.urls") +class TestOwaspUrls(SimpleTestCase): + def test_overview_pdf_url(self): + url = reverse("project_health_metrics_overview_pdf") + assert url == "/project-health-metrics/overview/pdf/" + resolver = resolve(url) + assert resolver.func == generate_overview_pdf + + def test_project_health_metrics_pdf_url(self): + url = reverse("project_health_metrics_pdf", kwargs={"project_key": "test-project"}) + assert url == "/project-health-metrics/test-project/pdf/" + resolver = resolve(url) + assert resolver.func == generate_project_health_metrics_pdf diff --git a/backend/tests/apps/owasp/management/commands/owasp_sync_board_candidates_test.py b/backend/tests/apps/owasp/management/commands/owasp_sync_board_candidates_test.py index 827f3660d9..df23e68eaf 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_sync_board_candidates_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_sync_board_candidates_test.py @@ -1,18 +1,38 @@ +import json +from unittest.mock import Mock + +import pytest +from django.contrib.contenttypes.models import ContentType + from apps.owasp.management.commands.owasp_sync_board_candidates import Command class TestSyncBoardCandidatesCommand: - def test_get_candidate_name_from_filename(self): - command = Command() + @pytest.fixture + def command(self, mocker): + cmd = Command() + cmd.stdout = Mock() + cmd.stderr = Mock() + cmd.style = Mock() + + mock_ct = ContentType(app_label="owasp", model="boardofdirectors") + mock_ct.id = 1 + mocker.patch( + "django.contrib.contenttypes.models.ContentType.objects.get_for_model", + return_value=mock_ct, + ) + + return cmd + def test_get_candidate_name_from_filename(self, command): assert command.get_candidate_name_from_filename("john-doe.md") == "John Doe" assert command.get_candidate_name_from_filename("jane_smith.md") == "Jane Smith" assert ( command.get_candidate_name_from_filename("mary-ann-johnson.md") == "Mary Ann Johnson" ) + assert command.get_candidate_name_from_filename("Candidate-Name.md") == "Candidate Name" - def test_parse_candidate_metadata_valid(self): - command = Command() + def test_parse_candidate_metadata_valid(self, command): content = """--- name: John Doe email: john.doe@example.com @@ -29,16 +49,12 @@ def test_parse_candidate_metadata_valid(self): assert metadata["email"] == "john.doe@example.com" assert metadata["title"] == "Software Security Professional" - def test_parse_candidate_metadata_no_frontmatter(self): - command = Command() + def test_parse_candidate_metadata_no_frontmatter(self, command): content = "# Just a heading\n\nSome content" - metadata = command.parse_candidate_metadata(content) - assert metadata == {} - def test_parse_candidate_metadata_invalid_yaml(self): - command = Command() + def test_parse_candidate_metadata_invalid_yaml(self, command): content = """--- name: John Doe invalid: [unclosed @@ -47,5 +63,88 @@ def test_parse_candidate_metadata_invalid_yaml(self): Content""" metadata = command.parse_candidate_metadata(content) - assert metadata == {} + + def test_sync_year_candidates_success(self, command, mocker): + mocker.patch( + "apps.owasp.management.commands.owasp_sync_board_candidates.get_repository_file_content" + ) + + mock_board = Mock() + mock_board.id = 100 + mock_board_manager = Mock() + mock_board_manager.get_or_create.return_value = (mock_board, True) + mocker.patch( + "apps.owasp.models.board_of_directors.BoardOfDirectors.objects", mock_board_manager + ) + + mock_update_data = mocker.patch("apps.owasp.models.entity_member.EntityMember.update_data") + + repo_files = [{"name": "jane-doe.md", "download_url": "https://github.com/jane-doe.md"}] + + file_content = "---\nname: Jane Doe\nemail: jane@example.com\n---\nBio" + + def side_effect(url): + if "contents/2024" in url: + return json.dumps(repo_files) + if "jane-doe.md" in url: + return file_content + return "" + + mocker.patch( + "apps.owasp.management.commands.owasp_sync_board_candidates.get_repository_file_content", + side_effect=side_effect, + ) + + count = command.sync_year_candidates(2024) + + assert count == 1 + mock_update_data.assert_called_once() + args, kwargs = mock_update_data.call_args + data_arg = args[0] + assert kwargs["save"] + assert data_arg["member_name"] == "Jane Doe" + + def test_sync_year_candidates_api_error(self, command, mocker): + mocker.patch( + "apps.owasp.models.board_of_directors.BoardOfDirectors.objects.get_or_create", + return_value=(Mock(), True), + ) + + mocker.patch( + "apps.owasp.management.commands.owasp_sync_board_candidates.get_repository_file_content", + side_effect=OSError("API Error"), + ) + + count = command.sync_year_candidates(2024) + assert count == 0 + command.stderr.write.assert_called() + + def test_handle_specific_year(self, command, mocker): + mock_sync = mocker.patch.object(command, "sync_year_candidates", return_value=5) + + command.handle(year=2025) + + mock_sync.assert_called_with(2025) + command.stdout.write.assert_called_with(mocker.ANY) + + def test_handle_all_years(self, command, mocker): + mock_sync = mocker.patch.object(command, "sync_year_candidates", return_value=2) + + files_json = json.dumps( + [ + {"name": "2024", "type": "dir"}, + {"name": "2025", "type": "dir"}, + {"name": "README.md", "type": "file"}, + ] + ) + mocker.patch( + "apps.owasp.management.commands.owasp_sync_board_candidates.get_repository_file_content", + return_value=files_json, + ) + + command.handle() + + assert mock_sync.call_count == 2 + mock_sync.assert_any_call(2024) + mock_sync.assert_any_call(2025) diff --git a/backend/tests/apps/slack/events/app_mention_test.py b/backend/tests/apps/slack/events/app_mention_test.py new file mode 100644 index 0000000000..3e547843ba --- /dev/null +++ b/backend/tests/apps/slack/events/app_mention_test.py @@ -0,0 +1,102 @@ +from apps.slack.events.app_mention import AppMention + + +class TestAppMention: + def test_handle_event_disabled_in_conversation(self, mocker): + event = {"channel": "C123456", "text": "Hello"} + client = mocker.Mock() + mock_objects = mocker.Mock() + mocker.patch("apps.slack.events.app_mention.Conversation.objects", mock_objects) + mock_qs = mocker.Mock() + mock_objects.filter.return_value = mock_qs + mock_qs.exists.return_value = False + + mock_logger = mocker.patch("apps.slack.events.app_mention.logger") + + handler = AppMention() + handler.handle_event(event, client) + + mock_logger.warning.assert_called_with( + "NestBot AI Assistant is not enabled for this conversation." + ) + client.chat_postMessage.assert_not_called() + + def test_handle_event_no_query_in_text(self, mocker): + event = {"channel": "C123456", "text": ""} + client = mocker.Mock() + mock_objects = mocker.Mock() + mocker.patch("apps.slack.events.app_mention.Conversation.objects", mock_objects) + mock_qs = mocker.Mock() + mock_objects.filter.return_value = mock_qs + mock_qs.exists.return_value = True + + mock_logger = mocker.patch("apps.slack.events.app_mention.logger") + + handler = AppMention() + handler.handle_event(event, client) + + mock_logger.warning.assert_called_with("No query found in app mention") + client.chat_postMessage.assert_not_called() + + def test_handle_event_query_in_blocks(self, mocker): + event = { + "channel": "C123456", + "text": "", + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [{"type": "text", "text": " Help me "}], + } + ], + } + ], + "ts": "123.456", + } + client = mocker.Mock() + client.chat_postMessage.return_value = {"ts": "999.999"} + + mock_objects = mocker.Mock() + mocker.patch("apps.slack.events.app_mention.Conversation.objects", mock_objects) + mock_qs = mocker.Mock() + mock_objects.filter.return_value = mock_qs + mock_qs.exists.return_value = True + + mock_get_blocks = mocker.patch( + "apps.slack.events.app_mention.get_blocks", + return_value=[{"type": "section", "text": {"text": "Response"}}], + ) + + handler = AppMention() + handler.handle_event(event, client) + + client.chat_postMessage.assert_called() + mock_get_blocks.assert_called_with(query="Help me") + client.chat_update.assert_called_with( + channel="C123456", + ts="999.999", + blocks=[{"type": "section", "text": {"text": "Response"}}], + text="Help me", + ) + + def test_handle_event_simple_text_query(self, mocker): + event = {"channel": "C123456", "text": "Simple query", "ts": "123.456"} + client = mocker.Mock() + client.chat_postMessage.return_value = {"ts": "999.999"} + + mock_objects = mocker.Mock() + mocker.patch("apps.slack.events.app_mention.Conversation.objects", mock_objects) + mock_qs = mocker.Mock() + mock_objects.filter.return_value = mock_qs + mock_qs.exists.return_value = True + + mocker.patch("apps.slack.events.app_mention.get_blocks", return_value=[]) + + handler = AppMention() + handler.handle_event(event, client) + + client.chat_update.assert_called() + _, kwargs = client.chat_update.call_args + assert kwargs["text"] == "Simple query" diff --git a/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py b/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py new file mode 100644 index 0000000000..9e854b802d --- /dev/null +++ b/backend/tests/apps/slack/management/commands/owasp_match_channels_test.py @@ -0,0 +1,107 @@ +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.project import Project +from apps.slack.management.commands.owasp_match_channels import Command +from apps.slack.models import Conversation + + +class TestOwaspMatchChannels: + def test_handle_dry_run(self, mocker): + mock_chapter = mocker.Mock(spec=Chapter, id=1) + mock_chapter.name = "OWASP Chapter One" + mock_chapter.__str__ = lambda x: x.name + + mock_conv = mocker.Mock(spec=Conversation, id=10) + mock_conv.name = "chapter-one" + + mock_conv_qs = mocker.Mock() + mock_conv_qs.only.return_value.iterator.return_value = [mock_conv] + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.Conversation.objects", + mock_conv_qs, + ) + + mock_chapter_qs = mocker.Mock() + mock_chapter_qs.filter.return_value.only.return_value.iterator.return_value = [ + mock_chapter + ] + mocker.patch("apps.owasp.models.chapter.Chapter.objects", mock_chapter_qs) + + mock_committee_qs = mocker.Mock() + mock_committee_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.committee.Committee.objects", mock_committee_qs) + + mock_project_qs = mocker.Mock() + mock_project_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.project.Project.objects", mock_project_qs) + + mock_ec_qs = mocker.Mock() + mock_ec_qs.filter.return_value.exists.return_value = False + mocker.patch("apps.owasp.models.entity_channel.EntityChannel.objects", mock_ec_qs) + mock_ec_get_or_create = mock_ec_qs.get_or_create + + mock_ct = mocker.Mock() + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.ContentType.objects.get_for_model", + return_value=mock_ct, + ) + + command = Command() + command.stdout = mocker.Mock() + command.handle(dry_run=True, threshold=80) + + mock_ec_get_or_create.assert_not_called() + + def test_handle_creates_channel(self, mocker): + mock_project = mocker.Mock(spec=Project, id=2) + mock_project.name = "OWASP Juice Shop" + mock_conv = mocker.Mock(spec=Conversation, id=20) + mock_conv.name = "project-juice-shop" + + mock_conv_qs = mocker.Mock() + mock_conv_qs.only.return_value.iterator.return_value = [mock_conv] + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.Conversation.objects", + mock_conv_qs, + ) + + mock_chapter_qs = mocker.Mock() + mock_chapter_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.chapter.Chapter.objects", mock_chapter_qs) + + mock_committee_qs = mocker.Mock() + mock_committee_qs.filter.return_value.only.return_value.iterator.return_value = [] + mocker.patch("apps.owasp.models.committee.Committee.objects", mock_committee_qs) + + mock_project_qs = mocker.Mock() + mock_project_qs.filter.return_value.only.return_value.iterator.return_value = [ + mock_project + ] + mocker.patch("apps.owasp.models.project.Project.objects", mock_project_qs) + + mock_ct = mocker.Mock() + mocker.patch( + "apps.slack.management.commands.owasp_match_channels.ContentType.objects.get_for_model", + return_value=mock_ct, + ) + + mock_ec_qs = mocker.Mock() + mock_ec_qs.filter.return_value.exists.return_value = False + mocker.patch("apps.owasp.models.entity_channel.EntityChannel.objects", mock_ec_qs) + mock_get_or_create = mock_ec_qs.get_or_create + mock_get_or_create.return_value = (None, True) + + command = Command() + command.stdout = mocker.Mock() + command.handle(dry_run=False, threshold=80) + + mock_get_or_create.assert_called_once() + _, kwargs = mock_get_or_create.call_args + assert kwargs["entity_id"] == 2 + assert kwargs["channel_id"] == 20 + + def test_strip_owasp_prefix(self): + cmd = Command() + assert cmd.strip_owasp_prefix("OWASP Nest") == "Nest" + assert cmd.strip_owasp_prefix("Current OWASP Project") == "Current OWASP Project" + assert cmd.strip_owasp_prefix("OWASP - Project") == "Project" + assert cmd.strip_owasp_prefix("Simple Name") == "Simple Name" diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index a75033249e..17c660b4c2 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -65,6 +65,7 @@ arkid15r askowasp attisdropped bangbang +boardofdirectors bsky carryforward certbot