diff --git a/api/conftest.py b/api/conftest.py index 0a7860e9ab96..a799dfbf43d0 100644 --- a/api/conftest.py +++ b/api/conftest.py @@ -1,5 +1,6 @@ import os import typing +from unittest.mock import MagicMock import boto3 import pytest @@ -11,6 +12,7 @@ from moto import mock_dynamodb from mypy_boto3_dynamodb.service_resource import DynamoDBServiceResource, Table from pytest_django.plugin import blocking_manager_key +from pytest_mock import MockerFixture from rest_framework.authtoken.models import Token from rest_framework.test import APIClient from urllib3.connectionpool import HTTPConnectionPool @@ -85,6 +87,25 @@ def pytest_sessionstart(session: pytest.Session) -> None: fix_issue_3869() +@pytest.fixture() +def post_request_mock(mocker: MockerFixture) -> MagicMock: + def mocked_request(*args, **kwargs) -> None: + class MockResponse: + def __init__(self, json_data: str, status_code: int) -> None: + self.json_data = json_data + self.status_code = status_code + + def raise_for_status(self) -> None: + pass + + def json(self) -> str: + return self.json_data + + return MockResponse(json_data={"data": "data"}, status_code=200) + + return mocker.patch("requests.post", side_effect=mocked_request) + + @pytest.hookimpl(trylast=True) def pytest_configure(config: pytest.Config) -> None: if ( @@ -966,9 +987,16 @@ def flagsmith_environments_v2_table(dynamodb: DynamoDBServiceResource) -> Table: @pytest.fixture() -def feature_external_resource(feature: Feature) -> FeatureExternalResource: +def feature_external_resource( + feature: Feature, post_request_mock: MagicMock, mocker: MockerFixture +) -> FeatureExternalResource: + mocker.patch( + "integrations.github.client.generate_token", + return_value="mocked_token", + ) + return FeatureExternalResource.objects.create( - url="https://github.com/userexample/example-project-repo/issues/11", + url="https://github.com/repositoryownertest/repositorynametest/issues/11", type="GITHUB_ISSUE", feature=feature, metadata='{"status": "open"}', @@ -978,9 +1006,16 @@ def feature_external_resource(feature: Feature) -> FeatureExternalResource: @pytest.fixture() def feature_with_value_external_resource( feature_with_value: Feature, + post_request_mock: MagicMock, + mocker: MockerFixture, ) -> FeatureExternalResource: + mocker.patch( + "integrations.github.client.generate_token", + return_value="mocked_token", + ) + return FeatureExternalResource.objects.create( - url="https://github.com/userexample/example-project-repo/issues/11", + url="https://github.com/repositoryownertest/repositorynametest/issues/11", type="GITHUB_ISSUE", feature=feature_with_value, ) diff --git a/api/features/feature_external_resources/models.py b/api/features/feature_external_resources/models.py index 2151cb71b3d5..6dcfc4d99a7f 100644 --- a/api/features/feature_external_resources/models.py +++ b/api/features/feature_external_resources/models.py @@ -18,12 +18,13 @@ logger = logging.getLogger(__name__) -class FeatureExternalResource(LifecycleModelMixin, models.Model): - class ResourceType(models.TextChoices): - # GitHub external resource types - GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue" - GITHUB_PR = "GITHUB_PR", "GitHub PR" +class ResourceType(models.TextChoices): + # GitHub external resource types + GITHUB_ISSUE = "GITHUB_ISSUE", "GitHub Issue" + GITHUB_PR = "GITHUB_PR", "GitHub PR" + +class FeatureExternalResource(LifecycleModelMixin, models.Model): url = models.URLField() type = models.CharField(max_length=20, choices=ResourceType.choices) diff --git a/api/integrations/github/github.py b/api/integrations/github/github.py index af967bc8f2a5..ddbbdcc04698 100644 --- a/api/integrations/github/github.py +++ b/api/integrations/github/github.py @@ -1,6 +1,7 @@ import logging import typing from dataclasses import asdict +from typing import Any from core.helpers import get_current_site_url from django.utils.formats import get_format @@ -25,6 +26,24 @@ logger = logging.getLogger(__name__) +def handle_installation_deleted(payload: dict[str, Any]) -> None: + installation_id = payload.get("installation", {}).get("id") + if installation_id is not None: + try: + GithubConfiguration.objects.get(installation_id=installation_id).delete() + except GithubConfiguration.DoesNotExist: + logger.error( + f"GitHub Configuration with installation_id {installation_id} does not exist" + ) + else: + logger.error(f"The installation_id is not present in the payload: {payload}") + + +def handle_github_webhook_event(event_type: str, payload: dict[str, Any]) -> None: + if event_type == "installation" and payload.get("action") == "deleted": + handle_installation_deleted(payload) + + def generate_body_comment( name: str, event_type: str, diff --git a/api/integrations/github/migrations/0003_auto_20240528_0640.py b/api/integrations/github/migrations/0003_auto_20240528_0640.py new file mode 100644 index 000000000000..a9861fe87cf9 --- /dev/null +++ b/api/integrations/github/migrations/0003_auto_20240528_0640.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.25 on 2024-05-28 06:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('github', '0002_auto_20240502_1949'), + ] + + operations = [ + migrations.AlterModelOptions( + name='githubconfiguration', + options={'ordering': ('id',)}, + ), + migrations.AlterModelOptions( + name='githubrepository', + options={'ordering': ('id',)}, + ), + migrations.RemoveConstraint( + model_name='githubrepository', + name='unique_repository_data', + ), + migrations.AddConstraint( + model_name='githubrepository', + constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('github_configuration', 'project', 'repository_owner', 'repository_name'), name='unique_repository_data'), + ), + ] diff --git a/api/integrations/github/models.py b/api/integrations/github/models.py index 22dcd6ee5333..532e0760b9ff 100644 --- a/api/integrations/github/models.py +++ b/api/integrations/github/models.py @@ -1,4 +1,5 @@ import logging +import re from core.models import SoftDeleteExportableModel from django.db import models @@ -31,6 +32,7 @@ class Meta: condition=models.Q(deleted_at__isnull=True), ) ] + ordering = ("id",) class GithubRepository(LifecycleModelMixin, SoftDeleteExportableModel): @@ -57,8 +59,10 @@ class Meta: "repository_name", ], name="unique_repository_data", + condition=models.Q(deleted_at__isnull=True), ) ] + ordering = ("id",) @hook(BEFORE_DELETE) def delete_feature_external_resources( @@ -66,12 +70,17 @@ def delete_feature_external_resources( ) -> None: from features.feature_external_resources.models import ( FeatureExternalResource, + ResourceType, ) + pattern = re.escape(f"/{self.repository_owner}/{self.repository_name}/") + FeatureExternalResource.objects.filter( feature_id__in=self.project.features.values_list("id", flat=True), type__in=[ - FeatureExternalResource.ResourceType.GITHUB_ISSUE, - FeatureExternalResource.ResourceType.GITHUB_PR, + ResourceType.GITHUB_ISSUE, + ResourceType.GITHUB_PR, ], + # Filter by url containing the repository owner and name + url__regex=pattern, ).delete() diff --git a/api/integrations/github/views.py b/api/integrations/github/views.py index 67063bcc34ba..4cc2f3efea57 100644 --- a/api/integrations/github/views.py +++ b/api/integrations/github/views.py @@ -21,6 +21,7 @@ fetch_search_github_resource, ) from integrations.github.exceptions import DuplicateGitHubIntegration +from integrations.github.github import handle_github_webhook_event from integrations.github.helpers import github_webhook_payload_is_valid from integrations.github.models import GithubConfiguration, GithubRepository from integrations.github.permissions import HasPermissionToGithubConfiguration @@ -249,11 +250,8 @@ def github_webhook(request) -> Response: payload_body=payload, secret_token=secret, signature_header=signature ): data = json.loads(payload.decode("utf-8")) - # handle GitHub Webhook "installation" event with action type "deleted" - if github_event == "installation" and data["action"] == "deleted": - GithubConfiguration.objects.filter( - installation_id=data["installation"]["id"] - ).delete() + if github_event == "installation": + handle_github_webhook_event(event_type=github_event, payload=data) return Response({"detail": "Event processed"}, status=200) else: return Response({"detail": "Event bypassed"}, status=200) diff --git a/api/tests/unit/features/test_unit_feature_external_resources_views.py b/api/tests/unit/features/test_unit_feature_external_resources_views.py index 8fba7210dc05..504d28989e25 100644 --- a/api/tests/unit/features/test_unit_feature_external_resources_views.py +++ b/api/tests/unit/features/test_unit_feature_external_resources_views.py @@ -1,5 +1,7 @@ from datetime import datetime +from unittest.mock import MagicMock +import pytest import responses import simplejson as json from django.core.serializers.json import DjangoJSONEncoder @@ -63,21 +65,6 @@ def expected_segment_comment_body( ) -def mocked_requests_post(*args, **kwargs): - class MockResponse: - def __init__(self, json_data, status_code): - self.json_data = json_data - self.status_code = status_code - - def raise_for_status(self) -> None: - pass - - def json(self): - return self.json_data - - return MockResponse(json_data={"data": "data"}, status_code=200) - - @responses.activate def test_create_feature_external_resource( admin_client_new: APIClient, @@ -87,16 +74,13 @@ def test_create_feature_external_resource( project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + post_request_mock: MagicMock, mocker: MockerFixture, ) -> None: # Given - mock_generate_token = mocker.patch( + mocker.patch( "integrations.github.client.generate_token", - ) - - mock_generate_token.return_value = "mocked_token" - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post + return_value="mocked_token", ) feature_external_resource_data = { @@ -145,7 +129,7 @@ def test_create_feature_external_resource( "`value`", ) ) - github_request_mock.assert_called_with( + post_request_mock.assert_called_with( "https://api.github.com/repos/repoowner/repo-name/issues/35/comments", json={"body": f"{expected_comment_body}"}, headers={ @@ -272,15 +256,15 @@ def test_cannot_create_feature_external_resource_when_the_type_is_incorrect( def test_cannot_create_feature_external_resource_due_to_unique_constraint( admin_client_new: APIClient, feature: Feature, - feature_external_resource: FeatureExternalResource, project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + feature_external_resource: FeatureExternalResource, ) -> None: # Given feature_external_resource_data = { "type": "GITHUB_ISSUE", - "url": "https://github.com/userexample/example-project-repo/issues/11", + "url": "https://github.com/repositoryownertest/repositorynametest/issues/11", "feature": feature.id, } url = reverse( @@ -300,21 +284,15 @@ def test_cannot_create_feature_external_resource_due_to_unique_constraint( def test_delete_feature_external_resource( admin_client_new: APIClient, - feature_external_resource: FeatureExternalResource, feature: Feature, project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, - mocker, + feature_external_resource: FeatureExternalResource, + post_request_mock: MagicMock, + mocker: MockerFixture, ) -> None: # Given - mock_generate_token = mocker.patch( - "integrations.github.client.generate_token", - ) - mock_generate_token.return_value = "mocked_token" - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post - ) url = reverse( "api-v1:projects:feature-external-resources-detail", args=[project.id, feature.id, feature_external_resource.id], @@ -324,8 +302,8 @@ def test_delete_feature_external_resource( response = admin_client_new.delete(url) # Then - github_request_mock.assert_called_with( - "https://api.github.com/repos/userexample/example-project-repo/issues/11/comments", + post_request_mock.assert_called_with( + "https://api.github.com/repos/repositoryownertest/repositorynametest/issues/11/comments", json={ "body": "### The feature flag `Test Feature1` was unlinked from the issue/PR" }, @@ -345,11 +323,11 @@ def test_delete_feature_external_resource( @responses.activate def test_get_feature_external_resources( admin_client_new: APIClient, - feature_external_resource: FeatureExternalResource, feature: Feature, project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + feature_external_resource: FeatureExternalResource, mocker: MockerFixture, ) -> None: # Given @@ -363,7 +341,7 @@ def test_get_feature_external_resources( responses.add( method="GET", - url=f"{GITHUB_API_URL}repos/userexample/example-project-repo/issues/11", + url=f"{GITHUB_API_URL}repos/repositoryownertest/repositorynametest/issues/11", status=200, json={"title": "resource name", "state": "open"}, ) @@ -377,11 +355,11 @@ def test_get_feature_external_resources( def test_get_feature_external_resource( admin_client_new: APIClient, - feature_external_resource: FeatureExternalResource, feature: Feature, project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + feature_external_resource: FeatureExternalResource, ) -> None: # Given url = reverse( @@ -403,26 +381,20 @@ def test_create_github_comment_on_feature_state_updated( staff_user: FFAdminUser, staff_client: APIClient, with_environment_permissions: WithEnvironmentPermissionsCallable, - feature_external_resource: FeatureExternalResource, feature: Feature, project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + post_request_mock: MagicMock, mocker: MockerFixture, environment: Environment, + feature_external_resource: FeatureExternalResource, ) -> None: # Given with_environment_permissions([UPDATE_FEATURE_STATE], environment.id, False) feature_state = FeatureState.objects.get( feature=feature, environment=environment.id ) - mock_generate_token = mocker.patch( - "integrations.github.client.generate_token", - ) - mock_generate_token.return_value = "mocked_token" - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post - ) payload = dict(FeatureStateSerializerBasic(instance=feature_state).data) @@ -454,8 +426,8 @@ def test_create_github_comment_on_feature_state_updated( assert response.status_code == status.HTTP_200_OK - github_request_mock.assert_called_with( - "https://api.github.com/repos/userexample/example-project-repo/issues/11/comments", + post_request_mock.assert_called_with( + "https://api.github.com/repos/repositoryownertest/repositorynametest/issues/11/comments", json={"body": expected_body_comment}, headers={ "Accept": "application/vnd.github.v3+json", @@ -469,21 +441,18 @@ def test_create_github_comment_on_feature_state_updated( def test_create_github_comment_on_feature_was_deleted( admin_client: APIClient, with_environment_permissions: WithEnvironmentPermissionsCallable, - feature_external_resource: FeatureExternalResource, feature: Feature, project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + feature_external_resource: FeatureExternalResource, + post_request_mock: MagicMock, mocker: MockerFixture, ) -> None: # Given - mock_generate_token = mocker.patch( + mocker.patch( "integrations.github.client.generate_token", - ) - mock_generate_token.return_value = "mocked_token" - - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post + return_value="mocked_token", ) url = reverse( @@ -497,8 +466,8 @@ def test_create_github_comment_on_feature_was_deleted( # Then assert response.status_code == status.HTTP_204_NO_CONTENT - github_request_mock.assert_called_with( - "https://api.github.com/repos/userexample/example-project-repo/issues/11/comments", + post_request_mock.assert_called_with( + "https://api.github.com/repos/repositoryownertest/repositorynametest/issues/11/comments", json={"body": "### The Feature Flag `Test Feature1` was deleted"}, headers={ "Accept": "application/vnd.github.v3+json", @@ -512,22 +481,20 @@ def test_create_github_comment_on_feature_was_deleted( def test_create_github_comment_on_segment_override_updated( feature_with_value: Feature, segment_override_for_feature_with_value: FeatureState, - feature_with_value_external_resource: FeatureExternalResource, project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + post_request_mock: MagicMock, mocker: MockerFixture, environment: Environment, admin_client: APIClient, + feature_with_value_external_resource: FeatureExternalResource, ) -> None: # Given feature_state = segment_override_for_feature_with_value - mock_generate_token = mocker.patch( + mocker.patch( "integrations.github.client.generate_token", - ) - mock_generate_token.return_value = "mocked_token" - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post + return_value="mocked_token", ) payload = dict(WritableNestedFeatureStateSerializer(instance=feature_state).data) @@ -563,8 +530,8 @@ def test_create_github_comment_on_segment_override_updated( assert response.status_code == status.HTTP_200_OK - github_request_mock.assert_called_with( - "https://api.github.com/repos/userexample/example-project-repo/issues/11/comments", + post_request_mock.assert_called_with( + "https://api.github.com/repos/repositoryownertest/repositorynametest/issues/11/comments", json={"body": expected_comment_body}, headers={ "Accept": "application/vnd.github.v3+json", @@ -578,19 +545,17 @@ def test_create_github_comment_on_segment_override_updated( def test_create_github_comment_on_segment_override_deleted( segment_override_for_feature_with_value: FeatureState, feature_with_value_segment: FeatureSegment, - feature_with_value_external_resource: FeatureExternalResource, github_configuration: GithubConfiguration, github_repository: GithubRepository, + post_request_mock: MagicMock, mocker: MockerFixture, admin_client_new: APIClient, + feature_with_value_external_resource: FeatureExternalResource, ) -> None: # Given - mock_generate_token = mocker.patch( + mocker.patch( "integrations.github.client.generate_token", - ) - mock_generate_token.return_value = "mocked_token" - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post + return_value="mocked_token", ) url = reverse( @@ -605,8 +570,8 @@ def test_create_github_comment_on_segment_override_deleted( assert response.status_code == status.HTTP_204_NO_CONTENT - github_request_mock.assert_called_with( - "https://api.github.com/repos/userexample/example-project-repo/issues/11/comments", + post_request_mock.assert_called_with( + "https://api.github.com/repos/repositoryownertest/repositorynametest/issues/11/comments", json={ "body": "### The Segment Override `segment` for Feature Flag `feature_with_value` was deleted" }, @@ -621,7 +586,6 @@ def test_create_github_comment_on_segment_override_deleted( def test_create_github_comment_using_v2( admin_client_new: APIClient, - feature_external_resource: FeatureExternalResource, environment_v2_versioning: Environment, segment: Segment, feature: Feature, @@ -629,19 +593,11 @@ def test_create_github_comment_using_v2( project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + feature_external_resource: FeatureExternalResource, + post_request_mock: MagicMock, mocker: MockerFixture, ) -> None: - # Given - mock_generate_token = mocker.patch( - "integrations.github.client.generate_token", - ) - mock_generate_token.return_value = "mocked_token" - - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post - ) - environment_feature_version = EnvironmentFeatureVersion.objects.create( environment=environment_v2_versioning, feature=feature ) @@ -687,8 +643,8 @@ def test_create_github_comment_using_v2( ) ) - github_request_mock.assert_called_with( - "https://api.github.com/repos/userexample/example-project-repo/issues/11/comments", + post_request_mock.assert_called_with( + "https://api.github.com/repos/repositoryownertest/repositorynametest/issues/11/comments", json={"body": expected_comment_body}, headers={ "Accept": "application/vnd.github.v3+json", @@ -703,7 +659,6 @@ def test_create_github_comment_using_v2( def test_create_github_comment_using_v2_fails_on_wrong_params( admin_client_new: APIClient, - feature_external_resource: FeatureExternalResource, environment_v2_versioning: Environment, segment: Segment, feature: Feature, @@ -711,19 +666,12 @@ def test_create_github_comment_using_v2_fails_on_wrong_params( project: Project, github_configuration: GithubConfiguration, github_repository: GithubRepository, + feature_external_resource: FeatureExternalResource, + post_request_mock: MagicMock, mocker: MockerFixture, ) -> None: # Given - mock_generate_token = mocker.patch( - "integrations.github.client.generate_token", - ) - mock_generate_token.return_value = "mocked_token" - - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post - ) - environment_feature_version = EnvironmentFeatureVersion.objects.create( environment=environment_v2_versioning, feature=feature ) @@ -752,10 +700,10 @@ def test_create_github_comment_using_v2_fails_on_wrong_params( # Then assert response.status_code == status.HTTP_400_BAD_REQUEST - github_request_mock.assert_not_called() @responses.activate +@pytest.mark.freeze_time("2024-05-28T09:09:47.325132+00:00") def test_create_feature_external_resource_on_environment_with_v2( admin_client_new: APIClient, project: Project, @@ -763,21 +711,17 @@ def test_create_feature_external_resource_on_environment_with_v2( github_repository: GithubRepository, segment_override_for_feature_with_value: FeatureState, environment_v2_versioning: Environment, + post_request_mock: MagicMock, mocker: MockerFixture, ) -> None: # Given feature_id = segment_override_for_feature_with_value.feature_id - mock_generate_token = mocker.patch( + mocker.patch( "integrations.github.client.generate_token", return_value="mocked_token", ) - mock_generate_token.return_value = "mocked_token" - github_request_mock = mocker.patch( - "requests.post", side_effect=mocked_requests_post - ) - feature_external_resource_data = { "type": "GITHUB_ISSUE", "url": "https://github.com/repoowner/repo-name/issues/35", @@ -826,7 +770,7 @@ def test_create_feature_external_resource_on_environment_with_v2( assert response.status_code == status.HTTP_201_CREATED - github_request_mock.assert_called_with( + post_request_mock.assert_called_with( "https://api.github.com/repos/repoowner/repo-name/issues/35/comments", json={"body": f"{expected_comment_body}"}, headers={ diff --git a/api/tests/unit/integrations/github/test_unit_github_views.py b/api/tests/unit/integrations/github/test_unit_github_views.py index 16d8df6f3c30..9fbe84d57974 100644 --- a/api/tests/unit/integrations/github/test_unit_github_views.py +++ b/api/tests/unit/integrations/github/test_unit_github_views.py @@ -23,7 +23,19 @@ from projects.models import Project WEBHOOK_PAYLOAD = json.dumps({"installation": {"id": 1234567}, "action": "deleted"}) +WEBHOOK_PAYLOAD_WITH_AN_INVALID_INSTALLATION_ID = json.dumps( + {"installation": {"id": 765432}, "action": "deleted"} +) +WEBHOOK_PAYLOAD_WITHOUT_INSTALLATION_ID = json.dumps( + {"installation": {"test": 765432}, "action": "deleted"} +) WEBHOOK_SIGNATURE = "sha1=57a1426e19cdab55dd6d0c191743e2958e50ccaa" +WEBHOOK_SIGNATURE_WITH_AN_INVALID_INSTALLATION_ID = ( + "sha1=081eef49d04df27552587d5df1c6b76e0fe20d21" +) +WEBHOOK_SIGNATURE_WITHOUT_INSTALLATION_ID = ( + "sha1=f99796bd3cebb902864e87ed960c5cca8772ff67" +) WEBHOOK_SECRET = "secret-key" @@ -300,9 +312,9 @@ def test_cannot_create_github_repository_due_to_unique_constraint( def test_github_delete_repository( admin_client_new: APIClient, organisation: Organisation, - feature_external_resource: FeatureExternalResource, github_configuration: GithubConfiguration, github_repository: GithubRepository, + feature_external_resource: FeatureExternalResource, mocker: MockerFixture, ) -> None: # Given @@ -316,8 +328,10 @@ def test_github_delete_repository( ) for feature in github_repository.project.features.all(): assert FeatureExternalResource.objects.filter(feature=feature).exists() + # When response = admin_client_new.delete(url) + # Then assert response.status_code == status.HTTP_204_NO_CONTENT for feature in github_repository.project.features.all(): @@ -349,6 +363,9 @@ def mocked_requests_get_issues_and_pull_requests(*args, **kwargs): "id": 1, "title": "Title 1", "number": 101, + "state": "Open", + "merged": False, + "draft": True, }, ], "total_count": 1, @@ -618,6 +635,7 @@ def test_verify_github_webhook_payload_returns_false_on_no_signature_header() -> def test_github_webhook_delete_installation( + api_client: APIClient, github_configuration: GithubConfiguration, ) -> None: # Given @@ -625,8 +643,7 @@ def test_github_webhook_delete_installation( url = reverse("api-v1:github-webhook") # When - client = APIClient() - response = client.post( + response = api_client.post( path=url, data=WEBHOOK_PAYLOAD, content_type="application/json", @@ -639,6 +656,58 @@ def test_github_webhook_delete_installation( assert not GithubConfiguration.objects.filter(installation_id=1234567).exists() +def test_github_webhook_with_non_existing_installation( + api_client: APIClient, + github_configuration: GithubConfiguration, + mocker: MockerFixture, +) -> None: + # Given + settings.GITHUB_WEBHOOK_SECRET = WEBHOOK_SECRET + url = reverse("api-v1:github-webhook") + mocker_logger = mocker.patch("integrations.github.github.logger") + + # When + response = api_client.post( + path=url, + data=WEBHOOK_PAYLOAD_WITH_AN_INVALID_INSTALLATION_ID, + content_type="application/json", + HTTP_X_HUB_SIGNATURE=WEBHOOK_SIGNATURE_WITH_AN_INVALID_INSTALLATION_ID, + HTTP_X_GITHUB_EVENT="installation", + ) + + # Then + mocker_logger.error.assert_called_once_with( + "GitHub Configuration with installation_id 765432 does not exist" + ) + assert response.status_code == status.HTTP_200_OK + + +def test_github_webhook_without_installation_id( + api_client: APIClient, + github_configuration: GithubConfiguration, + mocker: MockerFixture, +) -> None: + # Given + settings.GITHUB_WEBHOOK_SECRET = WEBHOOK_SECRET + url = reverse("api-v1:github-webhook") + mocker_logger = mocker.patch("integrations.github.github.logger") + + # When + response = api_client.post( + path=url, + data=WEBHOOK_PAYLOAD_WITHOUT_INSTALLATION_ID, + content_type="application/json", + HTTP_X_HUB_SIGNATURE=WEBHOOK_SIGNATURE_WITHOUT_INSTALLATION_ID, + HTTP_X_GITHUB_EVENT="installation", + ) + + # Then + mocker_logger.error.assert_called_once_with( + "The installation_id is not present in the payload: {'installation': {'test': 765432}, 'action': 'deleted'}" + ) + assert response.status_code == status.HTTP_200_OK + + def test_github_webhook_fails_on_signature_header_missing( github_configuration: GithubConfiguration, ) -> None: