Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add update self hosted settings mutation #559

Merged
merged 20 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from unittest.mock import patch

import pytest
from asgiref.sync import async_to_sync
from django.contrib.auth.models import AnonymousUser
from django.test import TransactionTestCase, override_settings

from codecov.commands.exceptions import Unauthenticated, ValidationError
from codecov_auth.commands.owner.interactors.update_self_hosted_settings import (
UpdateSelfHostedSettingsInteractor,
)
from codecov_auth.tests.factories import OwnerFactory


class UpdateSelfHostedSettingsInteractorTest(TransactionTestCase):
@async_to_sync
def execute(
self,
current_user,
input={
"shouldAutoActivate": None,
},
):
return UpdateSelfHostedSettingsInteractor(None, "github", current_user).execute(
input=input,
)

@override_settings(IS_ENTERPRISE=True)
def test_update_self_hosted_settings_when_auto_activate_is_true(self):
owner = OwnerFactory(plan_auto_activate=False)
self.execute(current_user=owner, input={"shouldAutoActivate": True})
owner.refresh_from_db()
assert owner.plan_auto_activate == True

@override_settings(IS_ENTERPRISE=True)
def test_update_self_hosted_settings_when_auto_activate_is_false(self):
owner = OwnerFactory(plan_auto_activate=True)
self.execute(current_user=owner, input={"shouldAutoActivate": False})
owner.refresh_from_db()
assert owner.plan_auto_activate == False

@override_settings(IS_ENTERPRISE=False)
def test_validation_error_when_not_self_hosted_instance(self):
owner = OwnerFactory(plan_auto_activate=True)
with pytest.raises(ValidationError):
self.execute(
current_user=owner,
input={
"shouldAutoActivate": False,
},
)

@override_settings(IS_ENTERPRISE=True)
def test_user_is_not_authenticated(self):
with pytest.raises(Unauthenticated) as e:
self.execute(
current_user=AnonymousUser(),
input={
"shouldAutoActivate": False,
},
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from dataclasses import dataclass

from django.conf import settings

import services.self_hosted as self_hosted
from codecov.commands.base import BaseInteractor
from codecov.commands.exceptions import Unauthenticated, ValidationError
from codecov.db import sync_to_async
from services.refresh import RefreshService


@dataclass
class UpdateSelfHostedSettingsInput:
auto_activate_members: bool = False


class UpdateSelfHostedSettingsInteractor(BaseInteractor):
def validate(self) -> None:
if not self.current_user.is_authenticated:
raise Unauthenticated()

if not settings.IS_ENTERPRISE:
raise ValidationError(
"enable_autoactivation and disable_autoactivation are only available in self-hosted environments"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: enable_auto_activation vs. enable_autoactivation

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see it either way, but because we're camelcasing the input on AutoActivate, it seems more consistent

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are what the methods are called in self_hosted.py

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooo okay makes sense

)

@sync_to_async
def execute(self, input: UpdateSelfHostedSettingsInput) -> None:
self.validate()
typed_input = UpdateSelfHostedSettingsInput(
auto_activate_members=input.get("shouldAutoActivate"),
)

should_auto_activate = typed_input.auto_activate_members
if should_auto_activate:
self_hosted.enable_autoactivation()
else:
self_hosted.disable_autoactivation()
4 changes: 4 additions & 0 deletions codecov_auth/commands/owner/owner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .interactors.trigger_sync import TriggerSyncInteractor
from .interactors.update_default_organization import UpdateDefaultOrganizationInteractor
from .interactors.update_profile import UpdateProfileInteractor
from .interactors.update_self_hosted_settings import UpdateSelfHostedSettingsInteractor


class OwnerCommands(BaseCommand):
Expand Down Expand Up @@ -82,3 +83,6 @@ def cancel_trial(self, org_username: str) -> None:
return self.get_interactor(CancelTrialInteractor).execute(
org_username=org_username
)

def update_self_hosted_settings(self, input) -> None:
return self.get_interactor(UpdateSelfHostedSettingsInteractor).execute(input)
70 changes: 70 additions & 0 deletions graphql_api/tests/mutation/test_update_self_hosted_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import pytest
from django.test import TransactionTestCase, override_settings

from codecov.commands.exceptions import ValidationError
from codecov_auth.tests.factories import OwnerFactory
from graphql_api.tests.helper import GraphQLTestHelper

query = """
mutation($input: UpdateSelfHostedSettingsInput!) {
updateSelfHostedSettings(input: $input) {
error {
__typename
... on ResolverError {
message
}
}
}
}
"""


class UpdateSelfHostedSettingsTest(GraphQLTestHelper, TransactionTestCase):
def _request(self, owner=None):
return self.gql_request(
query,
variables={"input": {"shouldAutoActivate": True}},
owner=owner,
)

def _request_deactivate(self, owner=None):
return self.gql_request(
query,
variables={"input": {"shouldAutoActivate": False}},
owner=owner,
)

@override_settings(IS_ENTERPRISE=True)
def test_unauthenticated(self):
assert self._request() == {
"updateSelfHostedSettings": {
"error": {
"__typename": "UnauthenticatedError",
"message": "You are not authenticated",
}
}
}

@override_settings(IS_ENTERPRISE=True)
def test_authenticated_enable_autoactivation(self):
owner = OwnerFactory()
assert self._request(owner=owner) == {"updateSelfHostedSettings": None}

@override_settings(IS_ENTERPRISE=True)
def test_authenticate_disable_autoactivation(self):
owner = OwnerFactory()
assert self._request_deactivate(owner=owner) == {
"updateSelfHostedSettings": None
}

@override_settings(IS_ENTERPRISE=False)
def test_invalid_settings(self):
owner = OwnerFactory()
assert self._request(owner=owner) == {
"updateSelfHostedSettings": {
"error": {
"__typename": "ValidationError",
"message": "enable_autoactivation and disable_autoactivation are only available in self-hosted environments",
}
}
}
20 changes: 20 additions & 0 deletions graphql_api/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ def test_seats_used_self_hosted(self, activated_owners):
},
}

def test_plan_auto_activate(self):
data = self.gql_request("query { config { planAutoActivate }}")
assert data == {
"config": {
"planAutoActivate": None,
},
}

@override_settings(IS_ENTERPRISE=True)
@patch("services.self_hosted.is_autoactivation_enabled")
def test_plan_auto_activate_self_hosted(self, is_autoactivation_enabled):
is_autoactivation_enabled.return_value = True

data = self.gql_request("query { config { planAutoActivate }}")
assert data == {
"config": {
"planAutoActivate": True,
},
}

def test_seats_limit(self):
data = self.gql_request("query { config { seatsLimit }}")
assert data == {
Expand Down
1 change: 1 addition & 0 deletions graphql_api/types/config/config.graphql
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
type Config {
loginProviders: [LoginProvider!]!
planAutoActivate: Boolean
seatsUsed: Int
seatsLimit: Int
isTimescaleEnabled: Boolean!
Expand Down
11 changes: 10 additions & 1 deletion graphql_api/types/config/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from distutils.util import strtobool
from typing import List
from typing import List, Optional

from ariadne import ObjectType
from django.conf import settings
from graphql.type.definition import GraphQLResolveInfo

import services.self_hosted as self_hosted
from codecov.db import sync_to_async
Expand Down Expand Up @@ -65,6 +66,14 @@ def resolve_sync_providers(_, info) -> List[str]:
return sync_providers


@config_bindable.field("planAutoActivate")
def resolve_plan_auto_activate(_, info: GraphQLResolveInfo) -> Optional[bool]:
if not settings.IS_ENTERPRISE:
return None

return self_hosted.is_autoactivation_enabled()


@config_bindable.field("seatsUsed")
@sync_to_async
def resolve_seats_used(_, info):
Expand Down
2 changes: 2 additions & 0 deletions graphql_api/types/mutation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .sync_with_git_provider import gql_sync_with_git_provider
from .update_default_organization import gql_update_default_organization
from .update_profile import gql_update_profile
from .update_self_hosted_settings import gql_update_self_hosted_settings

mutation = ariadne_load_local_graphql(__file__, "mutation.graphql")
mutation = mutation + gql_create_api_token
Expand All @@ -39,3 +40,4 @@
mutation = mutation + gql_start_trial
mutation = mutation + gql_cancel_trial
mutation = mutation + gql_delete_component_measurements
mutation = mutation + gql_update_self_hosted_settings
1 change: 1 addition & 0 deletions graphql_api/types/mutation/mutation.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ type Mutation {
saveSentryState(input: SaveSentryStateInput!): SaveSentryStatePayload
saveTermsAgreement(input: SaveTermsAgreementInput!): SaveTermsAgreementPayload
deleteComponentMeasurements(input: DeleteComponentMeasurementsInput!): DeleteComponentMeasurementsPayload
updateSelfHostedSettings(input: UpdateSelfHostedSettingsInput!): UpdateSelfHostedSettingsPayload
}
7 changes: 7 additions & 0 deletions graphql_api/types/mutation/mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
resolve_update_default_organization,
)
from .update_profile import error_update_profile, resolve_update_profile
from .update_self_hosted_settings import (
error_update_self_hosted_settings,
resolve_update_self_hosted_settings,
)

mutation_bindable = MutationType()

Expand Down Expand Up @@ -69,6 +73,8 @@
mutation_bindable.field("deleteComponentMeasurements")(
resolve_delete_component_measurements
)
mutation_bindable.field("updateSelfHostedSettings")(resolve_update_self_hosted_settings)


mutation_resolvers = [
mutation_bindable,
Expand All @@ -90,4 +96,5 @@
error_save_terms_agreement,
error_start_trial,
error_cancel_trial,
error_update_self_hosted_settings,
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from graphql_api.helpers.ariadne import ariadne_load_local_graphql

from .update_self_hosted_settings import (
error_update_self_hosted_settings,
resolve_update_self_hosted_settings,
)

gql_update_self_hosted_settings = ariadne_load_local_graphql(
__file__, "update_self_hosted_settings.graphql"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
union UpdateSelfHostedSettingsError = UnauthenticatedError | ValidationError

type UpdateSelfHostedSettingsPayload {
error: UpdateSelfHostedSettingsError
}

input UpdateSelfHostedSettingsInput {
shouldAutoActivate: Boolean!
}
Original file line number Diff line number Diff line change
@@ -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_update_self_hosted_settings(_, info, input):
command: OwnerCommands = info.context["executor"].get_command("owner")
return await command.update_self_hosted_settings(input)


error_update_self_hosted_settings = UnionType("UpdateSelfHostedSettingsError")
error_update_self_hosted_settings.type_resolver(resolve_union_error_type)
Loading