diff --git a/codecov_auth/commands/owner/interactors/set_upload_token_required.py b/codecov_auth/commands/owner/interactors/set_upload_token_required.py new file mode 100644 index 0000000000..9a0113b648 --- /dev/null +++ b/codecov_auth/commands/owner/interactors/set_upload_token_required.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + +from codecov.commands.base import BaseInteractor +from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError +from codecov.db import sync_to_async +from codecov_auth.helpers import current_user_part_of_org +from codecov_auth.models import Owner + + +@dataclass +class SetUploadTokenRequiredInput: + upload_token_required: bool + org_username: str + + +class SetUploadTokenRequiredInteractor(BaseInteractor): + def validate(self, owner_obj, upload_token_required): + if not self.current_user.is_authenticated: + raise Unauthenticated() + if not owner_obj: + raise ValidationError("Owner not found") + if not current_user_part_of_org(self.current_owner, owner_obj): + raise Unauthorized() + if not owner_obj.is_admin(self.current_owner): + raise Unauthorized("Admin authorization required") + if upload_token_required is None: + raise ValidationError("upload_token_required must be either True or False") + + @sync_to_async + def execute(self, input: dict[str, bool]): + typed_input = SetUploadTokenRequiredInput( + upload_token_required=input.get("upload_token_required"), + org_username=input.get("org_username"), + ) + + owner_obj = Owner.objects.filter( + username=typed_input.org_username, service=self.service + ).first() + + self.validate(owner_obj, typed_input.upload_token_required) + + owner_obj.upload_token_required_for_public_repos = bool( + typed_input.upload_token_required + ) + owner_obj.save() + + return diff --git a/codecov_auth/commands/owner/interactors/tests/test_set_upload_token_required.py b/codecov_auth/commands/owner/interactors/tests/test_set_upload_token_required.py new file mode 100644 index 0000000000..143d1320f5 --- /dev/null +++ b/codecov_auth/commands/owner/interactors/tests/test_set_upload_token_required.py @@ -0,0 +1,92 @@ +import pytest +from asgiref.sync import async_to_sync +from django.test import TransactionTestCase + +from codecov.commands.exceptions import Unauthenticated, Unauthorized, ValidationError +from codecov_auth.tests.factories import OwnerFactory + +from ..set_upload_token_required import SetUploadTokenRequiredInteractor + + +class SetUploadTokenRequiredInteractorTest(TransactionTestCase): + def setUp(self): + self.service = "github" + self.current_user = OwnerFactory(username="codecov-user") + self.owner = OwnerFactory( + username="codecov-owner", + service=self.service, + ) + self.owner_with_admins = OwnerFactory( + username="codecov-admin-owner", + service=self.service, + admins=[self.current_user.ownerid], + ) + + @async_to_sync + async def execute( + self, current_user, org_username=None, upload_token_required=True + ): + interactor = SetUploadTokenRequiredInteractor(current_user, self.service) + return await interactor.execute( + { + "upload_token_required": upload_token_required, + "org_username": org_username, + } + ) + + def test_user_is_not_authenticated(self): + with pytest.raises(Unauthenticated): + self.execute( + current_user=None, + org_username=self.owner.username, + ) + + def test_validation_error_when_owner_not_found(self): + with pytest.raises(ValidationError): + self.execute( + current_user=self.current_user, + org_username="non-existent-user", + ) + + def test_unauthorized_error_when_user_is_not_admin(self): + with pytest.raises(Unauthorized): + self.execute( + current_user=self.current_user, + org_username=self.owner.username, + ) + + def test_set_upload_token_required_when_user_is_admin(self): + self.current_user.organizations = [self.owner_with_admins.ownerid] + self.current_user.save() + + self.execute( + current_user=self.current_user, + org_username=self.owner_with_admins.username, + ) + + self.owner_with_admins.refresh_from_db() + assert self.owner_with_admins.upload_token_required_for_public_repos == True + + def test_set_upload_token_required_to_false(self): + self.current_user.organizations = [self.owner_with_admins.ownerid] + self.current_user.save() + + self.execute( + current_user=self.current_user, + org_username=self.owner_with_admins.username, + upload_token_required=False, + ) + + self.owner_with_admins.refresh_from_db() + assert self.owner_with_admins.upload_token_required_for_public_repos == False + + def test_set_upload_token_required_to_null_raises_validation_error(self): + self.current_user.organizations = [self.owner_with_admins.ownerid] + self.current_user.save() + + with pytest.raises(ValidationError): + self.execute( + current_user=self.current_user, + org_username=self.owner_with_admins.username, + upload_token_required=None, + ) diff --git a/codecov_auth/commands/owner/owner.py b/codecov_auth/commands/owner/owner.py index c98a4b7ded..2b97f5017a 100644 --- a/codecov_auth/commands/owner/owner.py +++ b/codecov_auth/commands/owner/owner.py @@ -14,6 +14,7 @@ from .interactors.revoke_user_token import RevokeUserTokenInteractor from .interactors.save_okta_config import SaveOktaConfigInteractor from .interactors.save_terms_agreement import SaveTermsAgreementInteractor +from .interactors.set_upload_token_required import SetUploadTokenRequiredInteractor from .interactors.set_yaml_on_owner import SetYamlOnOwnerInteractor from .interactors.start_trial import StartTrialInteractor from .interactors.store_codecov_metric import StoreCodecovMetricInteractor @@ -98,3 +99,6 @@ def store_codecov_metric( def save_okta_config(self, input) -> None: return self.get_interactor(SaveOktaConfigInteractor).execute(input) + + def set_upload_token_required(self, input) -> None: + return self.get_interactor(SetUploadTokenRequiredInteractor).execute(input) diff --git a/codecov_auth/commands/owner/tests/test_owner.py b/codecov_auth/commands/owner/tests/test_owner.py index 04aca26a2f..78c24a382f 100644 --- a/codecov_auth/commands/owner/tests/test_owner.py +++ b/codecov_auth/commands/owner/tests/test_owner.py @@ -134,3 +134,12 @@ def test_save_okta_config_delegate_to_interactor(self, interactor_mock): } self.command.save_okta_config(input_dict) interactor_mock.assert_called_once_with(input_dict) + + @patch("codecov_auth.commands.owner.owner.SetUploadTokenRequiredInteractor.execute") + def test_set_upload_token_required_delegate_to_interactor(self, interactor_mock): + input_dict = { + "upload_token_required": True, + "org_username": "codecov-user", + } + self.command.set_upload_token_required(input_dict) + interactor_mock.assert_called_once_with(input_dict) diff --git a/graphql_api/tests/mutation/test_set_upload_token_required.py b/graphql_api/tests/mutation/test_set_upload_token_required.py new file mode 100644 index 0000000000..879e28a474 --- /dev/null +++ b/graphql_api/tests/mutation/test_set_upload_token_required.py @@ -0,0 +1,101 @@ +from django.test import TransactionTestCase + +from codecov_auth.tests.factories import OwnerFactory +from graphql_api.tests.helper import GraphQLTestHelper + +query = """ +mutation($input: SetUploadTokenRequiredInput!) { + setUploadTokenRequired(input: $input) { + error { + __typename + ... on ResolverError { + message + } + } + } +} +""" + + +class SetUploadTokenRequiredTests(GraphQLTestHelper, TransactionTestCase): + def setUp(self): + self.org = OwnerFactory(username="codecov") + + def test_when_authenticated_updates_upload_token_required(self): + user = OwnerFactory( + organizations=[self.org.ownerid], + ) + self.org.admins = [user.ownerid] + self.org.save() + + data = self.gql_request( + query, + owner=user, + variables={ + "input": {"orgUsername": "codecov", "uploadTokenRequired": True} + }, + ) + + assert data["setUploadTokenRequired"] is None + + def test_when_validation_error_org_not_found(self): + data = self.gql_request( + query, + owner=self.org, + variables={ + "input": { + "orgUsername": "non_existent_org", + "uploadTokenRequired": True, + } + }, + ) + assert ( + data["setUploadTokenRequired"]["error"]["__typename"] == "ValidationError" + ) + + def test_when_unauthorized_non_admin(self): + non_admin_user = OwnerFactory( + organizations=[self.org.ownerid], + ) + + data = self.gql_request( + query, + owner=non_admin_user, + variables={ + "input": {"orgUsername": "codecov", "uploadTokenRequired": True} + }, + ) + + assert ( + data["setUploadTokenRequired"]["error"]["__typename"] == "UnauthorizedError" + ) + + def test_when_unauthenticated(self): + data = self.gql_request( + query, + variables={ + "input": {"orgUsername": "codecov", "uploadTokenRequired": True} + }, + ) + + assert ( + data["setUploadTokenRequired"]["error"]["__typename"] + == "UnauthenticatedError" + ) + + def test_when_not_part_of_org(self): + non_part_of_org_user = OwnerFactory( + organizations=[self.org.ownerid], + ) + + data = self.gql_request( + query, + owner=non_part_of_org_user, + variables={ + "input": {"orgUsername": "codecov", "uploadTokenRequired": True} + }, + ) + + assert ( + data["setUploadTokenRequired"]["error"]["__typename"] == "UnauthorizedError" + ) diff --git a/graphql_api/tests/test_owner.py b/graphql_api/tests/test_owner.py index 5e54a97aec..befdc697ae 100644 --- a/graphql_api/tests/test_owner.py +++ b/graphql_api/tests/test_owner.py @@ -11,7 +11,10 @@ from shared.django_apps.reports.models import ReportType from shared.upload.utils import UploaderType, insert_coverage_measurement -from codecov.commands.exceptions import MissingService, UnauthorizedGuestAccess +from codecov.commands.exceptions import ( + MissingService, + UnauthorizedGuestAccess, +) from codecov_auth.models import GithubAppInstallation, OwnerProfile from codecov_auth.tests.factories import ( AccountFactory, @@ -951,6 +954,43 @@ def test_fetch_repos_ai_features_enabled_all_repos(self, get_config_mock): data = self.gql_request(query, owner=self.owner) assert data["owner"]["aiEnabledRepos"] == ["b", "a"] + def test_fetch_upload_token_required(self): + owner = OwnerFactory(username="sample-owner", service="github") + query = """{ + owner(username: "%s") { + uploadTokenRequired + } + } + """ % (owner.username) + data = self.gql_request(query, owner=owner) + assert data["owner"]["uploadTokenRequired"] == True + + def test_fetch_upload_token_not_required(self): + owner = OwnerFactory(username="sample-owner", service="github") + owner.upload_token_required_for_public_repos = False + owner.save() + query = """{ + owner(username: "%s") { + uploadTokenRequired + } + } + """ % (owner.username) + data = self.gql_request(query, owner=owner) + assert data["owner"]["uploadTokenRequired"] == False + + def test_fetch_upload_token_user_not_part_of_org(self): + owner = OwnerFactory(username="sample", service="github") + user = OwnerFactory(username="sample-user", service="github") + query = """{ + owner(username: "%s") { + uploadTokenRequired + } + } + """ % (owner.username) + + data = self.gql_request(query, owner=user) + assert data["owner"]["uploadTokenRequired"] is None + def test_fetch_activated_user_count(self): user = OwnerFactory(username="sample-user") user2 = OwnerFactory(username="sample-user-2") diff --git a/graphql_api/types/mutation/__init__.py b/graphql_api/types/mutation/__init__.py index c4045976fe..244e529cc9 100644 --- a/graphql_api/types/mutation/__init__.py +++ b/graphql_api/types/mutation/__init__.py @@ -18,6 +18,7 @@ from .save_okta_config import gql_save_okta_config from .save_sentry_state import gql_save_sentry_state from .save_terms_agreement import gql_save_terms_agreement +from .set_upload_token_required import gql_set_upload_token_required from .set_yaml_on_owner import gql_set_yaml_on_owner from .start_trial import gql_start_trial from .store_event_metrics import gql_store_event_metrics @@ -53,3 +54,4 @@ mutation = mutation + gql_encode_secret_string mutation = mutation + gql_store_event_metrics mutation = mutation + gql_save_okta_config +mutation = mutation + gql_set_upload_token_required diff --git a/graphql_api/types/mutation/mutation.graphql b/graphql_api/types/mutation/mutation.graphql index ab519fe399..e8ab068efd 100644 --- a/graphql_api/types/mutation/mutation.graphql +++ b/graphql_api/types/mutation/mutation.graphql @@ -38,4 +38,5 @@ type Mutation { encodeSecretString(input: EncodeSecretStringInput!): EncodeSecretStringPayload storeEventMetric(input: StoreEventMetricsInput!): StoreEventMetricsPayload saveOktaConfig(input: SaveOktaConfigInput!): SaveOktaConfigPayload + setUploadTokenRequired(input: SetUploadTokenRequiredInput!): SetUploadTokenRequiredPayload } diff --git a/graphql_api/types/mutation/mutation.py b/graphql_api/types/mutation/mutation.py index a036a51077..1b487432f1 100644 --- a/graphql_api/types/mutation/mutation.py +++ b/graphql_api/types/mutation/mutation.py @@ -38,6 +38,10 @@ error_save_terms_agreement, resolve_save_terms_agreement, ) +from .set_upload_token_required import ( + error_set_upload_token_required, + resolve_set_upload_token_required, +) from .set_yaml_on_owner import error_set_yaml_error, resolve_set_yaml_on_owner from .start_trial import error_start_trial, resolve_start_trial from .store_event_metrics import error_store_event_metrics, resolve_store_event_metrics @@ -94,6 +98,7 @@ mutation_bindable.field("storeEventMetric")(resolve_store_event_metrics) mutation_bindable.field("saveOktaConfig")(resolve_save_okta_config) +mutation_bindable.field("setUploadTokenRequired")(resolve_set_upload_token_required) mutation_resolvers = [ mutation_bindable, @@ -122,4 +127,5 @@ error_encode_secret_string, error_store_event_metrics, error_save_okta_config, + error_set_upload_token_required, ] diff --git a/graphql_api/types/mutation/set_upload_token_required/__init__.py b/graphql_api/types/mutation/set_upload_token_required/__init__.py new file mode 100644 index 0000000000..55723e1e3c --- /dev/null +++ b/graphql_api/types/mutation/set_upload_token_required/__init__.py @@ -0,0 +1,12 @@ +from graphql_api.helpers.ariadne import ariadne_load_local_graphql + +from .set_upload_token_required import ( + error_set_upload_token_required, + resolve_set_upload_token_required, +) + +gql_set_upload_token_required = ariadne_load_local_graphql( + __file__, "set_upload_token_required.graphql" +) + +__all__ = ["error_set_upload_token_required", "resolve_set_upload_token_required"] diff --git a/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.graphql b/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.graphql new file mode 100644 index 0000000000..df5eec4197 --- /dev/null +++ b/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.graphql @@ -0,0 +1,13 @@ +union SetUploadTokenRequiredError = + UnauthenticatedError + | UnauthorizedError + | ValidationError + +type SetUploadTokenRequiredPayload { + error: SetUploadTokenRequiredError +} + +input SetUploadTokenRequiredInput { + orgUsername: String! + uploadTokenRequired: Boolean! +} diff --git a/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.py b/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.py new file mode 100644 index 0000000000..6e686a9ebb --- /dev/null +++ b/graphql_api/types/mutation/set_upload_token_required/set_upload_token_required.py @@ -0,0 +1,19 @@ +from ariadne import UnionType + +from codecov_auth.commands.owner import OwnerCommands +from graphql_api.helpers.mutation import ( + require_authenticated, + resolve_union_error_type, + wrap_error_handling_mutation, +) + + +@wrap_error_handling_mutation +@require_authenticated +async def resolve_set_upload_token_required(_, info, input): + command: OwnerCommands = info.context["executor"].get_command("owner") + return await command.set_upload_token_required(input) + + +error_set_upload_token_required = UnionType("SetUploadTokenRequiredError") +error_set_upload_token_required.type_resolver(resolve_union_error_type) diff --git a/graphql_api/types/owner/owner.graphql b/graphql_api/types/owner/owner.graphql index 4a327db515..0f3b7c7413 100644 --- a/graphql_api/types/owner/owner.graphql +++ b/graphql_api/types/owner/owner.graphql @@ -39,5 +39,6 @@ type Owner { yaml: String aiFeaturesEnabled: Boolean! aiEnabledRepos: [String] + uploadTokenRequired: Boolean activatedUserCount: Int } diff --git a/graphql_api/types/owner/owner.py b/graphql_api/types/owner/owner.py index a79e5c0953..922c2a25b9 100644 --- a/graphql_api/types/owner/owner.py +++ b/graphql_api/types/owner/owner.py @@ -369,6 +369,12 @@ def resolve_ai_enabled_repos( return list(queryset.values_list("name", flat=True)) +@owner_bindable.field("uploadTokenRequired") +@require_part_of_org +def resolve_upload_token_required(owner: Owner, info) -> bool | None: + return owner.upload_token_required_for_public_repos + + @owner_bindable.field("activatedUserCount") @sync_to_async @require_part_of_org