diff --git a/readthedocs/core/templatetags/readthedocs/socialaccounts.py b/readthedocs/core/templatetags/readthedocs/socialaccounts.py index fd952d6766f..9f767271a81 100644 --- a/readthedocs/core/templatetags/readthedocs/socialaccounts.py +++ b/readthedocs/core/templatetags/readthedocs/socialaccounts.py @@ -1,7 +1,11 @@ from allauth.socialaccount.adapter import get_adapter +from allauth.socialaccount.providers.github.provider import GitHubProvider from django import forms from django import template +from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider +from readthedocs.oauth.utils import is_access_revoked as utils_is_access_revoked + register = template.Library() @@ -19,3 +23,21 @@ def can_be_disconnected(account): return True except forms.ValidationError: return False + + +@register.filter +def is_access_revoked(account): + """Check if access to the account is revoked.""" + return utils_is_access_revoked(account) + + +@register.filter +def has_github_app_account(account): + """Check if there is a GitHub App account matching this account.""" + if account.provider != GitHubProvider.id: + return False + + return account.user.socialaccount_set.filter( + provider=GitHubAppProvider.id, + uid=account.uid, + ).exists() diff --git a/readthedocs/oauth/migrate.py b/readthedocs/oauth/migrate.py new file mode 100644 index 00000000000..dc543311c39 --- /dev/null +++ b/readthedocs/oauth/migrate.py @@ -0,0 +1,371 @@ +"""This module contains the logic to help users migrate from the GitHub OAuth App to the GitHub App.""" + +from dataclasses import dataclass +from typing import Iterator +from urllib.parse import urlencode + +from allauth.socialaccount.models import SocialAccount +from allauth.socialaccount.providers.github.provider import GitHubProvider +from django.conf import settings + +from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider +from readthedocs.core.permissions import AdminPermission +from readthedocs.integrations.models import Integration +from readthedocs.oauth.constants import GITHUB +from readthedocs.oauth.constants import GITHUB_APP +from readthedocs.oauth.models import GitHubAccountType +from readthedocs.oauth.models import RemoteRepository +from readthedocs.oauth.services import GitHubAppService +from readthedocs.oauth.services import GitHubService +from readthedocs.projects.models import Project + + +@dataclass +class GitHubAccountTarget: + """Information about a GitHub account that is used as a target where to install the GitHub App.""" + + id: int + login: str + type: GitHubAccountType + avatar_url: str + profile_url: str + + +@dataclass +class InstallationTargetGroup: + """Group of repositories that should be installed in the same target (user or organization).""" + + target: GitHubAccountTarget + repository_ids: set[int] + + @property + def link(self): + """ + Create a link to install the GitHub App on the target with the required repositories pre-selected. + + See https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/migrating-oauth-apps-to-github-apps#prompt-users-to-install-your-github-app. + """ + base_url = ( + f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/permissions" + ) + query_params = urlencode( + { + "suggested_target_id": self.target.id, + "repository_ids[]": self.repository_ids, + }, + doseq=True, + ) + return f"{base_url}?{query_params}" + + @property + def installed(self): + """ + Check if the app has been installed in all required repositories. + + If the the list of `repository_ids` is not empty, it means that the app still needs to be installed in some repositories, + or that the app hasn't been installed at all in the target account. + """ + return not bool(self.repository_ids) + + +@dataclass +class MigrationTarget: + """Information about an individual project that needs to be migrated.""" + + project: Project + has_installation: bool + is_admin: bool + target_id: int + + @property + def installation_link(self): + """ + Create a link to install the GitHub App on the target repository. + + See https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/migrating-oauth-apps-to-github-apps + """ + base_url = ( + f"https://github.com/apps/{settings.GITHUB_APP_NAME}/installations/new/permissions" + ) + query_params = urlencode( + { + "suggested_target_id": self.target_id, + "repository_ids[]": self.project.remote_repository.remote_id, + } + ) + return f"{base_url}?{query_params}" + + @property + def can_be_migrated(self): + """ + Check if the project can be migrated. + + The project can be migrated if the user is an admin on the repository and the GitHub App is installed. + """ + return self.is_admin and self.has_installation + + +@dataclass +class MigrationResult: + """Result of a migration operation.""" + + webhook_removed: bool + ssh_key_removed: bool + + +class MigrationError(Exception): + """Error raised when a migration operation fails.""" + + pass + + +def get_installation_target_groups_for_user(user) -> list[InstallationTargetGroup]: + """Get all targets (accounts and organizations) that the user needs to install the GitHub App on.""" + # Since we don't save the ID of the owner of each repository, we group all repositories + # that we aren't able to identify the owner into the user's account. + # GitHub will ignore the repositories that the user doesn't own. + default_target_account = _get_default_github_account_target(user) + + targets = {} + for project, has_intallation, _ in _get_projects_missing_migration(user): + remote_repository = project.remote_repository + target_account = _get_github_account_target(remote_repository) or default_target_account + if target_account.id not in targets: + targets[target_account.id] = InstallationTargetGroup( + target=target_account, + repository_ids=set(), + ) + if not has_intallation: + targets[target_account.id].repository_ids.add(int(remote_repository.remote_id)) + + # Include accounts that have already migrated projects, + # so they are shown as "Installed" in the UI. + for project in get_migrated_projects(user): + remote_repository = project.remote_repository + target_account = _get_github_account_target(remote_repository) or default_target_account + if target_account.id not in targets: + targets[target_account.id] = InstallationTargetGroup( + target=target_account, + repository_ids=set(), + ) + + return list(targets.values()) + + +def _get_default_github_account_target(user) -> GitHubAccountTarget: + """ + Get the GitHub account from the user. + + We first try to get the account from our old GitHub OAuth App, + and fallback to the new GitHub App if we can't find it. + + .. note:: + + There are some users that have more than one GH account connected. + They will need to migrate each account at a time. + """ + # This needs to support multiple accounts.... + account = user.socialaccount_set.filter(provider=GitHubProvider.id).first() + if not account: + account = user.socialaccount_set.filter(provider=GitHubAppProvider.id).first() + + return GitHubAccountTarget( + # We shouldn't have users without a login, but just in case. + login=account.extra_data.get("login", "ghost"), + id=int(account.uid), + type=GitHubAccountType.USER, + avatar_url=account.get_avatar_url(), + profile_url=account.get_profile_url(), + ) + + +def _get_github_account_target(remote_repository) -> GitHubAccountTarget | None: + """ + Get the GitHub account target for a repository. + + This will return the account that owns the repository, if we can identify it. + For repositories owned by organizations, we return the organization account, + for repositories owned by users, we try to guess the account based on the repository owner + (as we don't save the owner ID in the remote repository object). + """ + remote_organization = remote_repository.organization + if remote_organization: + return GitHubAccountTarget( + login=remote_organization.slug, + id=int(remote_organization.remote_id), + type=GitHubAccountType.ORGANIZATION, + avatar_url=remote_organization.avatar_url, + profile_url=remote_organization.url, + ) + login = remote_repository.full_name.split("/", 1)[0] + account = SocialAccount.objects.filter( + provider__in=[GitHubProvider.id, GitHubAppProvider.id], extra_data__login=login + ).first() + if account: + return GitHubAccountTarget( + login=login, + id=int(account.uid), + type=GitHubAccountType.USER, + avatar_url=account.get_avatar_url(), + profile_url=account.get_profile_url(), + ) + return None + + +def _get_projects_missing_migration(user) -> Iterator[tuple[Project, bool, bool]]: + """ + Get all projects where the user has admin permissions that are still connected to the old GitHub OAuth App. + + Returns an iterator with the project, a boolean indicating if the GitHub App is installed on the repository, + and a boolean indicating if the user has admin permissions on the repository. + """ + projects = ( + AdminPermission.projects(user, admin=True) + .filter(remote_repository__vcs_provider=GITHUB) + .select_related( + "remote_repository", + "remote_repository__organization", + ) + ) + for project in projects: + remote_repository = project.remote_repository + has_installation = RemoteRepository.objects.filter( + remote_id=remote_repository.remote_id, + vcs_provider=GITHUB_APP, + github_app_installation__isnull=False, + ).exists() + is_admin = ( + RemoteRepository.objects.for_project_linking(user) + .filter( + remote_id=project.remote_repository.remote_id, + vcs_provider=GITHUB_APP, + github_app_installation__isnull=False, + ) + .exists() + ) + yield project, has_installation, is_admin + + +def get_migrated_projects(user): + """ + Get all projects from the user that are already migrated to the GitHub App. + + This is basically all projects that are connected to a remote repository from the GitHub App. + """ + return ( + AdminPermission.projects(user, admin=True) + .filter(remote_repository__vcs_provider=GITHUB_APP) + .select_related( + "remote_repository", + ) + ) + + +def get_valid_projects_missing_migration(user): + """ + Get all projects that the user can migrate to the GitHub App. + + This includes all projects that are connected to the old GitHub OAuth App, + where the user has admin permissions and the GitHub App is installed. + """ + for project, has_installation, is_admin in _get_projects_missing_migration(user): + if has_installation and is_admin: + yield project + + +def get_migration_targets(user) -> list[MigrationTarget]: + """ + Get all projects that the user needs to migrate to the GitHub App. + + This includes all projects that are connected to the old GitHub OAuth App, + doesn't matter if the user has admin permissions or the GitHub App is installed. + """ + targets = [] + default_target_account = _get_default_github_account_target(user) + for project, has_installation, is_admin in _get_projects_missing_migration(user): + remote_repository = project.remote_repository + target_account = _get_github_account_target(remote_repository) or default_target_account + targets.append( + MigrationTarget( + project=project, + has_installation=has_installation, + is_admin=is_admin, + target_id=target_account.id, + ) + ) + return targets + + +def get_old_app_link() -> str: + """ + Get the link to the old GitHub OAuth App settings page. + + Useful so users can revoke the old app. + """ + client_id = settings.SOCIALACCOUNT_PROVIDERS["github"]["APPS"][0]["client_id"] + return f"https://github.com/settings/connections/applications/{client_id}" + + +def migrate_project_to_github_app(project, user) -> MigrationResult: + """ + Migrate a project to the new GitHub App. + + This will remove the webhook and SSH key from the old GitHub OAuth App and + connect the project to the new GitHub App. + + Returns a MigrationResult with the status of the migration. + Raises a MigrationError if the project can't be migrated, + this should never happen as we don't allow migrating projects + that can't be migrated from the UI. + """ + # No remote repository, nothing to migrate. + if not project.remote_repository: + raise MigrationError("Project isn't connected to a repository") + + service_class = project.get_git_service_class() + + # Already migrated, nothing to do. + if service_class == GitHubAppService: + return MigrationResult(webhook_removed=True, ssh_key_removed=True) + + # Not a GitHub project, nothing to migrate. + if service_class != GitHubService: + raise MigrationError("Project isn't connected to a GitHub repository") + + new_remote_repository = RemoteRepository.objects.filter( + remote_id=project.remote_repository.remote_id, + vcs_provider=GITHUB_APP, + github_app_installation__isnull=False, + ).first() + + if not new_remote_repository: + raise MigrationError("You need to install the GitHub App on the repository") + + new_remote_repository = ( + RemoteRepository.objects.for_project_linking(user) + .filter( + remote_id=project.remote_repository.remote_id, + vcs_provider=GITHUB_APP, + github_app_installation__isnull=False, + ) + .first() + ) + if not new_remote_repository: + raise MigrationError("You must have admin permissions on the repository to migrate it") + + webhook_removed = False + ssh_key_removed = False + for service in service_class.for_project(project): + if not webhook_removed and service.remove_webhook(project): + webhook_removed = True + + if not ssh_key_removed and service.remove_ssh_key(project): + ssh_key_removed = True + + project.integrations.filter(integration_type=Integration.GITHUB_WEBHOOK).delete() + project.remote_repository = new_remote_repository + project.save() + return MigrationResult( + webhook_removed=webhook_removed, + ssh_key_removed=ssh_key_removed, + ) diff --git a/readthedocs/oauth/notifications.py b/readthedocs/oauth/notifications.py index 968c1be8ebd..496a7c83c9b 100644 --- a/readthedocs/oauth/notifications.py +++ b/readthedocs/oauth/notifications.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from readthedocs.notifications.constants import ERROR +from readthedocs.notifications.constants import WARNING from readthedocs.notifications.messages import Message from readthedocs.notifications.messages import registry @@ -14,6 +15,8 @@ MESSAGE_OAUTH_WEBHOOK_INVALID = "oauth:webhook:invalid" MESSAGE_OAUTH_BUILD_STATUS_FAILURE = "oauth:status:send-failed" MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_FAILED = "oauth:deploy-key:attached-failed" +MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED = "oauth:migration:webhook-not-removed" +MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED = "oauth:migration:ssh-key-not-removed" messages = [ Message( @@ -83,5 +86,31 @@ ), type=ERROR, ), + Message( + id=MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED, + header=_("Failed to remove webhook"), + body=_( + textwrap.dedent( + """ + Failed to remove webhook from the {{ repo_full_name }} repository, please remove it manually + from the repository settings (search for a webhook containing "{{ project_slug }}" in the URL). + """ + ).strip(), + ), + type=WARNING, + ), + Message( + id=MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED, + header=_("Failed to remove deploy key"), + body=_( + textwrap.dedent( + """ + Failed to remove deploy key from the {{ repo_full_name }} repository, please remove it manually + from the repository settings (search for a deploy key containing "{{ project_slug }}" in the title). + """ + ) + ), + type=WARNING, + ), ] registry.add(messages) diff --git a/readthedocs/oauth/services/github.py b/readthedocs/oauth/services/github.py index 8863afacc13..c12cc3be51b 100644 --- a/readthedocs/oauth/services/github.py +++ b/readthedocs/oauth/services/github.py @@ -8,6 +8,7 @@ from django.conf import settings from oauthlib.oauth2.rfc6749.errors import InvalidGrantError from oauthlib.oauth2.rfc6749.errors import TokenExpiredError +from requests.exceptions import HTTPError from requests.exceptions import RequestException from readthedocs.builds import utils as build_utils @@ -406,6 +407,61 @@ def update_webhook(self, project, integration): return (False, resp) + def remove_webhook(self, project): + """ + Remove GitHub webhook for the repository associated with the project. + + We delete all webhooks that match the URL of the webhook we set up. + The URLs can be in several formats, so we check for all of them: + + - https://app.readthedocs.org/api/v2/webhook/github// + - https://app.readthedocs.org/api/v2/webhook// + - https://readthedocs.org/api/v2/webhook/github// + - https://readthedocs.org/api/v2/webhook// + + If a webhook fails to be removed, we log the error and cancel the operation, + as if we weren't able to delete one webhook, we won't be able to delete the others either. + + If we didn't find any webhook to delete, we return True. + """ + owner, repo = build_utils.get_github_username_repo(url=project.repo) + + try: + resp = self.session.get(f"{self.base_api_url}/repos/{owner}/{repo}/hooks") + resp.raise_for_status() + data = resp.json() + except HTTPError: + log.info("Failed to get GitHub webhooks for project.") + return False + + hook_targets = [ + f"{settings.PUBLIC_API_URL}/api/v2/webhook/{project.slug}/", + f"{settings.PUBLIC_API_URL}/api/v2/webhook/github/{project.slug}/", + ] + hook_targets.append(hook_targets[0].replace("app.", "", 1)) + hook_targets.append(hook_targets[1].replace("app.", "", 1)) + + for hook in data: + hook_url = hook["config"]["url"] + for hook_target in hook_targets: + if hook_url.startswith(hook_target): + try: + self.session.delete( + f"{self.base_api_url}/repos/{owner}/{repo}/hooks/{hook['id']}" + ).raise_for_status() + except HTTPError: + log.info("Failed to remove GitHub webhook for project.") + return False + return True + + def remove_ssh_key(self, project): + """ + Remove the SSH key from the GitHub repository associated with the project. + + This is overridden in .com, as we don't make use of the SSH keys in .org. + """ + return True + def send_build_status(self, *, build, commit, status): """ Create GitHub commit status for project. diff --git a/readthedocs/oauth/utils.py b/readthedocs/oauth/utils.py index 776fdd20f1d..013f7c93b93 100644 --- a/readthedocs/oauth/utils.py +++ b/readthedocs/oauth/utils.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from readthedocs.integrations.models import Integration +from readthedocs.oauth.clients import get_oauth2_client from readthedocs.oauth.services import BitbucketService from readthedocs.oauth.services import GitHubService from readthedocs.oauth.services import GitLabService @@ -61,3 +62,20 @@ def update_webhook(project, integration, request=None): project.has_valid_webhook = False project.save() return False + + +def is_access_revoked(account): + """Check if the access token for the given account is revoked.""" + client = get_oauth2_client(account) + if client is None: + return True + + provider = account.get_provider() + oauth2_adapter = provider.get_oauth2_adapter(request=provider.request) + test_url = oauth2_adapter.profile_url + resp = client.get(test_url) + # NOTE: This has only been tested for GitHub, + # check if Gitlab and Bitbucket return 401. + if resp.status_code == 401: + return True + return False diff --git a/readthedocs/profiles/tests/__init__.py b/readthedocs/profiles/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/readthedocs/profiles/tests/test_views.py b/readthedocs/profiles/tests/test_views.py new file mode 100644 index 00000000000..1b2597273f3 --- /dev/null +++ b/readthedocs/profiles/tests/test_views.py @@ -0,0 +1,739 @@ +from unittest import mock + +import pytest +import requests_mock +from allauth.socialaccount.models import SocialAccount, SocialToken +from allauth.socialaccount.providers.github.provider import GitHubProvider +from django.conf import settings +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse +from django_dynamic_fixture import get + +from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider +from readthedocs.notifications.models import Notification +from readthedocs.oauth.constants import GITHUB, GITHUB_APP +from readthedocs.oauth.migrate import InstallationTargetGroup, MigrationTarget, GitHubAccountTarget +from readthedocs.oauth.models import ( + GitHubAccountType, + GitHubAppInstallation, + RemoteOrganization, + RemoteOrganizationRelation, + RemoteRepository, + RemoteRepositoryRelation, +) +from readthedocs.oauth.notifications import ( + MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED, + MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED, +) +from readthedocs.oauth.services.github import GitHubService +from readthedocs.projects.models import Project + + +@pytest.mark.skipif( + not settings.RTD_EXT_THEME_ENABLED, reason="Not applicable for the old theme" +) +@override_settings(GITHUB_APP_NAME="readthedocs") +class TestMigrateToGitHubAppView(TestCase): + def setUp(self): + self.user = get(User) + self.social_account_github = get( + SocialAccount, + provider=GitHubProvider.id, + user=self.user, + uid="1234", + extra_data={"login": "user"}, + ) + get( + SocialToken, + account=self.social_account_github, + ) + self.social_account_github_app = get( + SocialAccount, + provider=GitHubAppProvider.id, + user=self.user, + uid="1234", + extra_data={"login": "user"}, + ) + self.github_app_installation = get( + GitHubAppInstallation, + installation_id=1111, + target_id=int(self.social_account_github_app.uid), + target_type=GitHubAccountType.USER, + ) + + # Project with remote repository where the user is admin. + self.remote_repository_a = get( + RemoteRepository, + name="repo-a", + full_name="user/repo-a", + html_url="https://github.com/user/repo-a", + remote_id="1111", + vcs_provider=GITHUB, + ) + get( + RemoteRepositoryRelation, + user=self.user, + account=self.social_account_github, + remote_repository=self.remote_repository_a, + admin=True, + ) + self.project_with_remote_repository = get( + Project, + users=[self.user], + remote_repository=self.remote_repository_a, + ) + + # Project with remote repository where the user is not admin. + self.remote_repository_b = get( + RemoteRepository, + name="repo-b", + full_name="user/repo-b", + html_url="https://github.com/user/repo-b", + remote_id="2222", + vcs_provider=GITHUB, + ) + get( + RemoteRepositoryRelation, + user=self.user, + account=self.social_account_github, + remote_repository=self.remote_repository_b, + admin=False, + ) + self.project_with_remote_repository_no_admin = get( + Project, + users=[self.user], + remote_repository=self.remote_repository_b, + ) + + # Project with remote repository where the user doesn't have permissions at all. + self.remote_repository_c = get( + RemoteRepository, + name="repo-c", + full_name="user2/repo-c", + html_url="https://github.com/user2/repo-c", + remote_id="3333", + vcs_provider=GITHUB, + ) + get( + RemoteRepositoryRelation, + user=self.user, + account=self.social_account_github, + remote_repository=self.remote_repository_c, + admin=False, + ) + self.project_with_remote_repository_no_member = get( + Project, + users=[self.user], + remote_repository=self.remote_repository_c, + ) + + # Project connected to a remote repository that belongs to an organization. + self.remote_organization = get( + RemoteOrganization, + slug="org", + name="Organization", + remote_id="9999", + vcs_provider=GITHUB, + ) + get( + RemoteOrganizationRelation, + user=self.user, + account=self.social_account_github, + ) + self.remote_repository_d = get( + RemoteRepository, + name="repo-d", + full_name="org/repo-d", + html_url="https://github.com/org/repo-d", + remote_id="4444", + organization=self.remote_organization, + ) + get( + RemoteRepositoryRelation, + user=self.user, + account=self.social_account_github, + remote_repository=self.remote_repository_d, + admin=True, + ) + self.project_with_remote_organization = get( + Project, + users=[self.user], + remote_repository=self.remote_repository_d, + ) + self.github_app_organization_installation = get( + GitHubAppInstallation, + installation_id=2222, + target_id=int(self.remote_organization.remote_id), + target_type=GitHubAccountType.ORGANIZATION, + ) + + # Project without a remote repository. + self.project_without_remote_repository = get( + Project, + users=[self.user], + repo="https://github.com/user/repo-e", + ) + + self.url = reverse("migrate_to_github_app") + self.client.force_login(self.user) + + def _create_github_app_remote_repository(self, remote_repository): + new_remote_repository = get( + RemoteRepository, + name=remote_repository.name, + full_name=remote_repository.full_name, + html_url=remote_repository.html_url, + remote_id=remote_repository.remote_id, + vcs_provider=GITHUB_APP, + github_app_installation=self.github_app_installation, + ) + if remote_repository.organization: + new_remote_repository.organization = get( + RemoteOrganization, + slug=remote_repository.organization.slug, + name=remote_repository.organization.name, + remote_id=remote_repository.organization.remote_id, + vcs_provider=GITHUB_APP, + ) + new_remote_repository.github_app_installation = ( + self.github_app_organization_installation + ) + new_remote_repository.save() + for relation in remote_repository.remote_repository_relations.all(): + github_app_account = relation.user.socialaccount_set.get( + provider=GitHubAppProvider.id + ) + get( + RemoteRepositoryRelation, + user=relation.user, + account=github_app_account, + remote_repository=new_remote_repository, + admin=relation.admin, + ) + return new_remote_repository + + def test_user_without_github_account(self): + self.user.socialaccount_set.all().delete() + response = self.client.get(self.url) + assert response.status_code == 302 + response = self.client.get(reverse("projects_dashboard")) + content = response.content.decode() + print(content) + assert ( + "You don\\u0026#x27\\u003Bt have any GitHub account connected." in content + ) + + def test_user_without_github_account_but_with_github_app_account(self): + self.user.socialaccount_set.exclude(provider=GitHubAppProvider.id).delete() + response = self.client.get(self.url) + assert response.status_code == 302 + response = self.client.get(reverse("projects_dashboard")) + assert ( + "You have already migrated your account to the new GitHub App" + in response.content.decode() + ) + + @requests_mock.Mocker(kw="request") + def test_migration_page_initial_state(self, request): + request.get("https://api.github.com/user", status_code=200) + + self.user.socialaccount_set.filter(provider=GitHubAppProvider.id).delete() + response = self.client.get(self.url) + assert response.status_code == 200 + context = response.context + + assert context["step"] == "overview" + assert context["step_connect_completed"] is False + assert context["installation_target_groups"] == [ + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.social_account_github.uid), + login="user", + type=GitHubAccountType.USER, + avatar_url=self.social_account_github.get_avatar_url(), + profile_url=self.social_account_github.get_profile_url(), + ), + repository_ids={1111, 2222, 3333}, + ), + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.remote_organization.remote_id), + login="org", + type=GitHubAccountType.ORGANIZATION, + avatar_url=self.remote_organization.avatar_url, + profile_url=self.remote_organization.url, + ), + repository_ids={4444}, + ), + ] + assert context["github_app_name"] == "readthedocs" + assert context["migration_targets"] == [ + MigrationTarget( + project=self.project_with_remote_repository, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_admin, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_member, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_organization, + has_installation=False, + is_admin=False, + target_id=int(self.remote_organization.remote_id), + ), + ] + assert list(context["migrated_projects"]) == [] + assert ( + context["old_application_link"] + == "https://github.com/settings/connections/applications/123" + ) + assert context["step_revoke_completed"] is False + assert list(context["old_github_accounts"]) == [self.social_account_github] + + @requests_mock.Mocker(kw="request") + def test_migration_page_step_connect_done(self, request): + request.get("https://api.github.com/user", status_code=200) + response = self.client.get(self.url) + assert response.status_code == 200 + context = response.context + + assert context["step"] == "overview" + assert context["step_connect_completed"] is True + assert context["installation_target_groups"] == [ + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.social_account_github.uid), + login="user", + type=GitHubAccountType.USER, + avatar_url=self.social_account_github.get_avatar_url(), + profile_url=self.social_account_github.get_profile_url(), + ), + repository_ids={1111, 2222, 3333}, + ), + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.remote_organization.remote_id), + login="org", + type=GitHubAccountType.ORGANIZATION, + avatar_url=self.remote_organization.avatar_url, + profile_url=self.remote_organization.url, + ), + repository_ids={4444}, + ), + ] + assert context["github_app_name"] == "readthedocs" + assert context["migration_targets"] == [ + MigrationTarget( + project=self.project_with_remote_repository, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_admin, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_member, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_organization, + has_installation=False, + is_admin=False, + target_id=int(self.remote_organization.remote_id), + ), + ] + assert list(context["migrated_projects"]) == [] + assert ( + context["old_application_link"] + == "https://github.com/settings/connections/applications/123" + ) + assert context["step_revoke_completed"] is False + assert list(context["old_github_accounts"]) == [self.social_account_github] + + @requests_mock.Mocker(kw="request") + def test_migration_page_step_install_done(self, request): + request.get("https://api.github.com/user", status_code=200) + + self._create_github_app_remote_repository(self.remote_repository_a) + self._create_github_app_remote_repository(self.remote_repository_b) + self._create_github_app_remote_repository(self.remote_repository_d) + + response = self.client.get(self.url) + assert response.status_code == 200 + context = response.context + + assert context["step"] == "overview" + assert context["step_connect_completed"] is True + assert context["installation_target_groups"] == [ + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.social_account_github.uid), + login="user", + type=GitHubAccountType.USER, + avatar_url=self.social_account_github.get_avatar_url(), + profile_url=self.social_account_github.get_profile_url(), + ), + repository_ids={3333}, + ), + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.remote_organization.remote_id), + login="org", + type=GitHubAccountType.ORGANIZATION, + avatar_url=self.remote_organization.avatar_url, + profile_url=self.remote_organization.url, + ), + repository_ids=set(), + ), + ] + assert context["github_app_name"] == "readthedocs" + assert context["migration_targets"] == [ + MigrationTarget( + project=self.project_with_remote_repository, + has_installation=True, + is_admin=True, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_admin, + has_installation=True, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_member, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_organization, + has_installation=True, + is_admin=True, + target_id=int(self.remote_organization.remote_id), + ), + ] + assert list(context["migrated_projects"]) == [] + assert ( + context["old_application_link"] + == "https://github.com/settings/connections/applications/123" + ) + assert context["step_revoke_completed"] is False + assert list(context["old_github_accounts"]) == [self.social_account_github] + + @requests_mock.Mocker(kw="request") + @mock.patch.object(GitHubService, "remove_webhook") + @mock.patch.object(GitHubService, "remove_ssh_key") + def test_migration_page_step_migrate_one_project( + self, remove_ssh_key, remove_webhook, request + ): + request.get("https://api.github.com/user", status_code=200) + + remove_ssh_key.return_value = True + remove_webhook.return_value = True + + self._create_github_app_remote_repository(self.remote_repository_a) + self._create_github_app_remote_repository(self.remote_repository_b) + self._create_github_app_remote_repository(self.remote_repository_d) + + response = self.client.post( + self.url, data={"project": self.project_with_remote_repository.slug} + ) + assert response.status_code == 302 + response = self.client.get(self.url) + context = response.context + + assert context["step"] == "overview" + assert context["step_connect_completed"] is True + assert context["installation_target_groups"] == [ + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.social_account_github.uid), + login="user", + type=GitHubAccountType.USER, + avatar_url=self.social_account_github.get_avatar_url(), + profile_url=self.social_account_github.get_profile_url(), + ), + repository_ids={3333}, + ), + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.remote_organization.remote_id), + login="org", + type=GitHubAccountType.ORGANIZATION, + avatar_url=self.remote_organization.avatar_url, + profile_url=self.remote_organization.url, + ), + repository_ids=set(), + ), + ] + assert context["github_app_name"] == "readthedocs" + assert context["migration_targets"] == [ + MigrationTarget( + project=self.project_with_remote_repository_no_admin, + has_installation=True, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_member, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_organization, + has_installation=True, + is_admin=True, + target_id=int(self.remote_organization.remote_id), + ), + ] + assert list(context["migrated_projects"]) == [ + self.project_with_remote_repository, + ] + assert ( + context["old_application_link"] + == "https://github.com/settings/connections/applications/123" + ) + assert context["step_revoke_completed"] is False + assert list(context["old_github_accounts"]) == [self.social_account_github] + + @requests_mock.Mocker(kw="request") + @mock.patch.object(GitHubService, "remove_webhook") + @mock.patch.object(GitHubService, "remove_ssh_key") + def test_migration_page_step_migrate_all_projects( + self, remove_ssh_key, remove_webhook, request + ): + request.get("https://api.github.com/user", status_code=200) + + remove_ssh_key.return_value = True + remove_webhook.return_value = True + + self._create_github_app_remote_repository(self.remote_repository_a) + self._create_github_app_remote_repository(self.remote_repository_b) + self._create_github_app_remote_repository(self.remote_repository_d) + + response = self.client.post(self.url) + assert response.status_code == 302 + response = self.client.get(self.url) + context = response.context + + assert context["step"] == "overview" + assert context["step_connect_completed"] is True + assert context["installation_target_groups"] == [ + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.social_account_github.uid), + login="user", + type=GitHubAccountType.USER, + avatar_url=self.social_account_github.get_avatar_url(), + profile_url=self.social_account_github.get_profile_url(), + ), + repository_ids={3333}, + ), + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.remote_organization.remote_id), + login="org", + type=GitHubAccountType.ORGANIZATION, + avatar_url=self.remote_organization.avatar_url, + profile_url=self.remote_organization.url, + ), + repository_ids=set(), + ), + ] + assert context["github_app_name"] == "readthedocs" + assert context["migration_targets"] == [ + MigrationTarget( + project=self.project_with_remote_repository_no_admin, + has_installation=True, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_member, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + ] + assert list(context["migrated_projects"]) == [ + self.project_with_remote_repository, + self.project_with_remote_organization, + ] + assert ( + context["old_application_link"] + == "https://github.com/settings/connections/applications/123" + ) + assert context["step_revoke_completed"] is False + assert list(context["old_github_accounts"]) == [self.social_account_github] + + @requests_mock.Mocker(kw="request") + @mock.patch.object(GitHubService, "remove_webhook") + @mock.patch.object(GitHubService, "remove_ssh_key") + def test_migration_page_step_migrate_one_project_with_errors( + self, remove_ssh_key, remove_webhook, request + ): + request.get("https://api.github.com/user", status_code=200) + + remove_ssh_key.return_value = False + remove_webhook.return_value = False + + self._create_github_app_remote_repository(self.remote_repository_a) + self._create_github_app_remote_repository(self.remote_repository_b) + self._create_github_app_remote_repository(self.remote_repository_d) + + response = self.client.post( + self.url, data={"project": self.project_with_remote_repository.slug} + ) + assert response.status_code == 302 + response = self.client.get(self.url) + context = response.context + + assert context["step"] == "overview" + assert context["step_connect_completed"] is True + assert context["installation_target_groups"] == [ + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.social_account_github.uid), + login="user", + type=GitHubAccountType.USER, + avatar_url=self.social_account_github.get_avatar_url(), + profile_url=self.social_account_github.get_profile_url(), + ), + repository_ids={3333}, + ), + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.remote_organization.remote_id), + login="org", + type=GitHubAccountType.ORGANIZATION, + avatar_url=self.remote_organization.avatar_url, + profile_url=self.remote_organization.url, + ), + repository_ids=set(), + ), + ] + assert context["github_app_name"] == "readthedocs" + assert context["migration_targets"] == [ + MigrationTarget( + project=self.project_with_remote_repository_no_admin, + has_installation=True, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_member, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_organization, + has_installation=True, + is_admin=True, + target_id=int(self.remote_organization.remote_id), + ), + ] + assert list(context["migrated_projects"]) == [ + self.project_with_remote_repository, + ] + assert ( + context["old_application_link"] + == "https://github.com/settings/connections/applications/123" + ) + assert context["step_revoke_completed"] is False + assert list(context["old_github_accounts"]) == [self.social_account_github] + + notifications = Notification.objects.for_user(self.user, self.user) + assert notifications.count() == 2 + assert notifications.filter( + message_id=MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED + ).exists() + assert notifications.filter( + message_id=MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED + ).exists() + + @requests_mock.Mocker(kw="request") + def test_migration_page_step_revoke_done(self, request): + request.get("https://api.github.com/user", status_code=401) + response = self.client.get(self.url) + assert response.status_code == 200 + context = response.context + + assert context["step"] == "overview" + assert context["step_connect_completed"] is True + assert context["installation_target_groups"] == [ + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.social_account_github.uid), + login="user", + type=GitHubAccountType.USER, + avatar_url=self.social_account_github.get_avatar_url(), + profile_url=self.social_account_github.get_profile_url(), + ), + repository_ids={1111, 2222, 3333}, + ), + InstallationTargetGroup( + target=GitHubAccountTarget( + id=int(self.remote_organization.remote_id), + login="org", + type=GitHubAccountType.ORGANIZATION, + avatar_url=self.remote_organization.avatar_url, + profile_url=self.remote_organization.url, + ), + repository_ids={4444}, + ), + ] + assert context["github_app_name"] == "readthedocs" + assert context["migration_targets"] == [ + MigrationTarget( + project=self.project_with_remote_repository, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_admin, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_repository_no_member, + has_installation=False, + is_admin=False, + target_id=int(self.social_account_github.uid), + ), + MigrationTarget( + project=self.project_with_remote_organization, + has_installation=False, + is_admin=False, + target_id=int(self.remote_organization.remote_id), + ), + ] + assert list(context["migrated_projects"]) == [] + assert ( + context["old_application_link"] + == "https://github.com/settings/connections/applications/123" + ) + assert context["step_revoke_completed"] is True + assert list(context["old_github_accounts"]) == [self.social_account_github] diff --git a/readthedocs/profiles/urls/private.py b/readthedocs/profiles/urls/private.py index 597019549fc..f65651a1421 100644 --- a/readthedocs/profiles/urls/private.py +++ b/readthedocs/profiles/urls/private.py @@ -41,6 +41,11 @@ views.AccountAdvertisingEdit.as_view(), name="account_advertising", ), + path( + "migrate-to-github-app/", + views.MigrateToGitHubAppView.as_view(), + name="migrate_to_github_app", + ), ] urlpatterns += account_urls diff --git a/readthedocs/profiles/views.py b/readthedocs/profiles/views.py index ef51566a028..4a12718bcd5 100644 --- a/readthedocs/profiles/views.py +++ b/readthedocs/profiles/views.py @@ -1,7 +1,11 @@ """Views for creating, editing and viewing site-specific user profiles.""" +from enum import StrEnum +from enum import auto + from allauth.account.views import LoginView as AllAuthLoginView from allauth.account.views import LogoutView as AllAuthLogoutView +from allauth.socialaccount.providers.github.provider import GitHubProvider from django.conf import settings from django.contrib import messages from django.contrib.auth import logout @@ -18,8 +22,10 @@ from vanilla import DetailView from vanilla import FormView from vanilla import ListView +from vanilla import TemplateView from vanilla import UpdateView +from readthedocs.allauth.providers.githubapp.provider import GitHubAppProvider from readthedocs.audit.filters import UserSecurityLogFilter from readthedocs.audit.models import AuditLog from readthedocs.core.forms import UserAdvertisingForm @@ -30,6 +36,16 @@ from readthedocs.core.models import UserProfile from readthedocs.core.permissions import AdminPermission from readthedocs.core.utils.extend import SettingsOverrideObject +from readthedocs.notifications.models import Notification +from readthedocs.oauth.migrate import get_installation_target_groups_for_user +from readthedocs.oauth.migrate import get_migrated_projects +from readthedocs.oauth.migrate import get_migration_targets +from readthedocs.oauth.migrate import get_old_app_link +from readthedocs.oauth.migrate import get_valid_projects_missing_migration +from readthedocs.oauth.migrate import migrate_project_to_github_app +from readthedocs.oauth.notifications import MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED +from readthedocs.oauth.notifications import MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED +from readthedocs.oauth.utils import is_access_revoked from readthedocs.organizations.models import Organization from readthedocs.projects.models import Project from readthedocs.projects.utils import get_csv_file @@ -278,3 +294,124 @@ def get_queryset(self): queryset=queryset, ) return self.filter.qs + + +class MigrationSteps(StrEnum): + overview = auto() + connect = auto() + install = auto() + migrate = auto() + revoke = auto() + disconnect = auto() + + +class MigrateToGitHubAppView(PrivateViewMixin, TemplateView): + """ + View to help users migrate their account to the new GitHub App. + + This view will guide the user through the process of migrating their account + and projects to the new GitHub App. + + A get request will show the overview of the migration process, + and each step to follow. A post request will migrate a single project + if the project slug is provided in the request, otherwise, it will migrate + all projects that can be migrated. + + In case we weren't able to remove the webhook or SSH key from the old GitHub App, + we create a notification for the user, so they can manually remove it. + """ + + template_name = "profiles/private/migrate_to_gh_app.html" + + def get(self, request, *args, **kwargs): + if not self._get_old_github_accounts().exists(): + if self._get_new_github_account(): + # NOTE: TBD what to do when the user has already migrated his account, + # but still has projects connected to the old integration. + msg = _("You have already migrated your account to the new GitHub App.") + else: + msg = _("You don't have any GitHub account connected.") + messages.info(request, msg) + return HttpResponseRedirect(reverse("homepage")) + + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + step = self.request.GET.get("step", MigrationSteps.overview) + if step not in MigrationSteps: + step = MigrationSteps.overview + context["step"] = step + + user = self.request.user + + context["step_connect_completed"] = self._has_new_accounts_for_old_accounts() + context["installation_target_groups"] = get_installation_target_groups_for_user(user) + context["github_app_name"] = settings.GITHUB_APP_NAME + context["migration_targets"] = get_migration_targets(user) + context["migrated_projects"] = get_migrated_projects(user) + context["old_application_link"] = get_old_app_link() + context["step_revoke_completed"] = self._is_access_to_old_github_accounts_revoked() + context["old_github_accounts"] = self._get_old_github_accounts() + return context + + def _is_access_to_old_github_accounts_revoked(self): + for old_account in self._get_old_github_accounts(): + if not is_access_revoked(old_account): + return False + return True + + def _has_new_accounts_for_old_accounts(self): + """ + Check if the user has connected his account to the new GitHub App. + + The new connected account must the same as the old one. + """ + old_accounts_uid = self._get_old_github_accounts().values_list("uid", flat=True) + number_of_new_accounts_for_old_accounts = ( + self.request.user.socialaccount_set.filter( + provider=GitHubAppProvider.id, + ) + .filter(uid__in=old_accounts_uid) + .count() + ) + return number_of_new_accounts_for_old_accounts == len(old_accounts_uid) + + def _get_new_github_account(self): + return self.request.user.socialaccount_set.filter(provider=GitHubAppProvider.id).first() + + def _get_old_github_accounts(self): + return self.request.user.socialaccount_set.filter(provider=GitHubProvider.id) + + def post(self, request, *args, **kwargs): + project_slug = request.POST.get("project") + if project_slug: + projects = AdminPermission.projects(request.user, admin=True).filter(slug=project_slug) + else: + projects = get_valid_projects_missing_migration(request.user) + + for project in projects: + result = migrate_project_to_github_app(project=project, user=request.user) + if not result.webhook_removed: + Notification.objects.add( + message_id=MESSAGE_OAUTH_WEBHOOK_NOT_REMOVED, + attached_to=request.user, + dismissable=True, + format_values={ + "repo_full_name": project.remote_repository.full_name, + "project_slug": project.slug, + }, + ) + if not result.ssh_key_removed: + Notification.objects.add( + message_id=MESSAGE_OAUTH_DEPLOY_KEY_NOT_REMOVED, + attached_to=request.user, + dismissable=True, + format_values={ + "repo_full_name": project.remote_repository.full_name, + "project_slug": project.slug, + }, + ) + + return HttpResponseRedirect(reverse("migrate_to_github_app") + "?step=migrate") diff --git a/readthedocs/rtd_tests/tests/test_oauth.py b/readthedocs/rtd_tests/tests/test_oauth.py index bc741aaaee0..466f24aef7b 100644 --- a/readthedocs/rtd_tests/tests/test_oauth.py +++ b/readthedocs/rtd_tests/tests/test_oauth.py @@ -1,6 +1,7 @@ import copy from unittest import mock +from allauth.socialaccount.providers.github.provider import GitHubProvider import requests_mock from allauth.socialaccount.models import SocialAccount, SocialToken from django.conf import settings @@ -869,6 +870,9 @@ def test_get_clone_token(self, request): assert clone_token == f"x-access-token:{token}" +@override_settings( + PUBLIC_API_URL="https://app.readthedocs.org", +) class GitHubOAuthTests(TestCase): fixtures = ["eric", "test_data"] @@ -882,8 +886,17 @@ def setUp(self): vcs_provider=GITHUB, ) self.privacy = settings.DEFAULT_PRIVACY_LEVEL + self.social_github_account = get( + SocialAccount, + user=self.user, + provider=GitHubProvider.id, + ) + get( + SocialToken, + account=self.social_github_account, + ) self.service = GitHubService( - user=self.user, account=get(SocialAccount, user=self.user) + user=self.user, account=self.social_github_account ) self.external_version = get(Version, project=self.project, type=EXTERNAL) self.external_build = get( @@ -936,6 +949,7 @@ def setUp(self): "updated_at": "2020-08-12T14:26:39Z", "type": "Organization", } + self.api_url = "https://api.github.com" def test_create_remote_repository(self): repo = self.service.create_repository( @@ -1318,8 +1332,8 @@ def test_get_provider_data_successful(self, session, mock_logger): self.integration.save() webhook_data = self.provider_data - rtd_webhook_url = "https://{domain}{path}".format( - domain=settings.PRODUCTION_DOMAIN, + rtd_webhook_url = "{domain}{path}".format( + domain=settings.PUBLIC_API_URL, path=reverse( "api_webhook", kwargs={ @@ -1387,6 +1401,143 @@ def test_get_provider_data_attribute_error(self, session, mock_logger): "GitHub webhook Listing failed for project.", ) + @requests_mock.Mocker(kw="request") + def test_remove_webhook_match_found(self, request): + assert self.project.repo == "https://github.com/pypa/pip" + assert self.project.slug == "pip" + request.get( + f"{self.api_url}/repos/pypa/pip/hooks", + json=[ + { + "id": 1, + "config": { + "url": "https://readthedocs.org/api/v2/webhook/github/pip/1111/", + }, + }, + { + "id": 2, + "config": { + "url": "https://readthedocs.org/api/v2/webhook/pip/1111/", + }, + }, + { + "id": 3, + "config": { + "url": "https://app.readthedocs.org/api/v2/webhook/github/pip/1111/", + }, + }, + { + "id": 4, + "config": { + "url": "https://app.readthedocs.org/api/v2/webhook/pip/1111/", + }, + }, + { + "id": 5, + "config": { + "url": "https://readthedocs.org/api/v2/webhook/github/another-project/1111/", + }, + }, + { + "id": 6, + "config": { + "url": "https://example.com/dont-delete-me/", + }, + }, + ] + ) + mock_request_deletions = [ + request.delete( + f"{self.api_url}/repos/pypa/pip/hooks/1", + ), + request.delete( + f"{self.api_url}/repos/pypa/pip/hooks/2", + ), + request.delete( + f"{self.api_url}/repos/pypa/pip/hooks/3", + ), + request.delete( + f"{self.api_url}/repos/pypa/pip/hooks/4", + ), + ] + assert self.service.remove_webhook(self.project) is True + for mock_request_deletion in mock_request_deletions: + assert mock_request_deletion.called_once + + @requests_mock.Mocker(kw="request") + def test_remove_webhook_match_found_error_to_delete(self, request): + assert self.project.repo == "https://github.com/pypa/pip" + assert self.project.slug == "pip" + request.get( + f"{self.api_url}/repos/pypa/pip/hooks", + json=[ + { + "id": 1, + "config": { + "url": "https://readthedocs.org/api/v2/webhook/github/pip/1111/", + }, + }, + { + "id": 2, + "config": { + "url": "https://readthedocs.org/api/v2/webhook/pip/1111/", + }, + }, + { + "id": 3, + "config": { + "url": "https://app.readthedocs.org/api/v2/webhook/github/pip/1111/", + }, + }, + { + "id": 4, + "config": { + "url": "https://app.readthedocs.org/api/v2/webhook/pip/1111/", + }, + }, + { + "id": 5, + "config": { + "url": "https://readthedocs.org/api/v2/webhook/github/another-project/1111/", + }, + }, + { + "id": 6, + "config": { + "url": "https://example.com/dont-delete-me/", + }, + }, + ] + ) + mock_request_deletion = request.delete( + f"{self.api_url}/repos/pypa/pip/hooks/1", + status_code=401, + ) + assert self.service.remove_webhook(self.project) is False + assert mock_request_deletion.called_once + + @requests_mock.Mocker(kw="request") + def test_remove_webhook_match_not_found(self, request): + assert self.project.repo == "https://github.com/pypa/pip" + assert self.project.slug == "pip" + request.get( + f"{self.api_url}/repos/pypa/pip/hooks", + json=[ + { + "id": 1, + "config": { + "url": "https://readthedocs.org/api/v2/webhook/github/another-project/1111/", + }, + }, + { + "id": 2, + "config": { + "url": "https://example.com/dont-delete-me/", + }, + }, + ] + ) + assert self.service.remove_webhook(self.project) is True class BitbucketOAuthTests(TestCase): fixtures = ["eric", "test_data"] diff --git a/readthedocs/rtd_tests/tests/test_privacy_urls.py b/readthedocs/rtd_tests/tests/test_privacy_urls.py index 54319deb9c8..c84e9613cac 100644 --- a/readthedocs/rtd_tests/tests/test_privacy_urls.py +++ b/readthedocs/rtd_tests/tests/test_privacy_urls.py @@ -546,6 +546,8 @@ def setUp(self): self.response_data.update( { "/accounts/login/": {"status_code": 302}, + # The test user doesn't have a GitHub account, so it's redirected to the home page. + "/accounts/migrate-to-github-app/": {"status_code": 302}, } ) @@ -562,6 +564,8 @@ def setUp(self): self.response_data.update( { "/accounts/login/": {"status_code": 302}, + # The test user doesn't have a GitHub account, so it's redirected to the home page. + "/accounts/migrate-to-github-app/": {"status_code": 302}, } )