Allow project deletion on replicas

Bug: Issue 15270
Change-Id: Ie6843ca21d96e776b55011a320d5b963e4d35b9c
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
new file mode 100644
index 0000000..8915e78
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionAction.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication.pull.api;
+
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.LocalFS;
+import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps;
+import java.util.Optional;
+import org.eclipse.jgit.transport.URIish;
+
+class ProjectDeletionAction
+    implements RestModifyView<ProjectResource, ProjectDeletionAction.DeleteInput> {
+  private static final PluginPermission DELETE_PROJECT =
+      new PluginPermission("delete-project", "deleteProject");
+
+  static class DeleteInput {}
+
+  private final GerritConfigOps gerritConfigOps;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  ProjectDeletionAction(GerritConfigOps gerritConfigOps, PermissionBackend permissionBackend) {
+    this.gerritConfigOps = gerritConfigOps;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public Response<?> apply(ProjectResource projectResource, DeleteInput input)
+      throws AuthException, BadRequestException, ResourceConflictException, Exception {
+
+    permissionBackend.user(projectResource.getUser()).check(DELETE_PROJECT);
+
+    Optional<URIish> maybeRepoURI =
+        gerritConfigOps.getGitRepositoryURI(String.format("%s.git", projectResource.getName()));
+
+    if (maybeRepoURI.isPresent()) {
+      if (new LocalFS(maybeRepoURI.get()).deleteProject(projectResource.getNameKey())) {
+        return Response.ok();
+      }
+      throw new UnprocessableEntityException(
+          String.format("Could not delete project %s", projectResource.getName()));
+    }
+    throw new ResourceNotFoundException(
+        String.format("Could not compute URI for repo: %s", projectResource.getName()));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
index a4ea953..44dd3bb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/PullReplicationFilter.java
@@ -76,6 +76,7 @@
   private ApplyObjectAction applyObjectAction;
   private ProjectInitializationAction projectInitializationAction;
   private UpdateHeadAction updateHEADAction;
+  private ProjectDeletionAction projectDeletionAction;
   private ProjectsCollection projectsCollection;
   private Gson gson;
   private Provider<CurrentUser> userProvider;
@@ -86,12 +87,14 @@
       ApplyObjectAction applyObjectAction,
       ProjectInitializationAction projectInitializationAction,
       UpdateHeadAction updateHEADAction,
+      ProjectDeletionAction projectDeletionAction,
       ProjectsCollection projectsCollection,
       Provider<CurrentUser> userProvider) {
     this.fetchAction = fetchAction;
     this.applyObjectAction = applyObjectAction;
     this.projectInitializationAction = projectInitializationAction;
     this.updateHEADAction = updateHEADAction;
+    this.projectDeletionAction = projectDeletionAction;
     this.projectsCollection = projectsCollection;
     this.userProvider = userProvider;
     this.gson = OutputFormat.JSON.newGsonBuilder().create();
@@ -135,6 +138,12 @@
         } else {
           httpResponse.sendError(SC_UNAUTHORIZED);
         }
+      } else if (isDeleteProjectAction(httpRequest)) {
+        if (userProvider.get().isIdentifiedUser()) {
+          writeResponse(httpResponse, doDeleteProject(httpRequest));
+        } else {
+          httpResponse.sendError(SC_UNAUTHORIZED);
+        }
       } else {
         chain.doFilter(request, response);
       }
@@ -195,6 +204,14 @@
   }
 
   @SuppressWarnings("unchecked")
+  private Response<String> doDeleteProject(HttpServletRequest httpRequest) throws Exception {
+    ProjectResource projectResource =
+        projectsCollection.parse(TopLevelResource.INSTANCE, getProjectName(httpRequest));
+    return (Response<String>)
+        projectDeletionAction.apply(projectResource, new ProjectDeletionAction.DeleteInput());
+  }
+
+  @SuppressWarnings("unchecked")
   private Response<Map<String, Object>> doFetch(HttpServletRequest httpRequest)
       throws IOException, RestApiException, PermissionBackendException {
     Input input = readJson(httpRequest, TypeLiteral.get(Input.class));
@@ -284,4 +301,9 @@
     return httpRequest.getRequestURI().matches("(/a)?/projects/[^/]+/HEAD")
         && "PUT".equals(httpRequest.getMethod());
   }
+
+  private boolean isDeleteProjectAction(HttpServletRequest httpRequest) {
+    return httpRequest.getRequestURI().matches("(/a)?/projects/[^/]+$")
+        && "DELETE".equals(httpRequest.getMethod());
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
index 14f8dfb..343ce66 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ActionITBase.java
@@ -48,6 +48,7 @@
 import org.apache.http.client.ClientProtocolException;
 import org.apache.http.client.CredentialsProvider;
 import org.apache.http.client.ResponseHandler;
+import org.apache.http.client.methods.HttpDelete;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.methods.HttpPut;
 import org.apache.http.client.protocol.HttpClientContext;
@@ -124,6 +125,12 @@
     return put;
   }
 
+  protected HttpDelete createDeleteRequest() {
+    HttpDelete delete = new HttpDelete(url);
+    delete.addHeader(new BasicHeader("Content-Type", "application/json"));
+    return delete;
+  }
+
   protected String createRef() throws Exception {
     return createRef(Project.nameKey(project + TEST_REPLICATION_SUFFIX));
   }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
new file mode 100644
index 0000000..4c09266
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectDeletionActionIT.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication.pull.api;
+
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.inject.Inject;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Test;
+
+public class ProjectDeletionActionIT extends ActionITBase {
+  public static final String INVALID_TEST_PROJECT_NAME = "\0";
+  public static final String DELETE_PROJECT_PERMISSION = "delete-project-deleteProject";
+
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  public void shouldReturnUnauthorizedForUserWithoutPermissionsOnReplica() throws Exception {
+    httpClientFactory
+        .create(source)
+        .execute(
+            createDeleteRequest(),
+            assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED),
+            getAnonymousContext());
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  public void shouldReturnOKWhenProjectIsDeletedOnReplica() throws Exception {
+    String testProjectName = project.get();
+    url = getURL(testProjectName);
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            createDeleteRequest(), assertHttpResponseCode(HttpServletResponse.SC_OK), getContext());
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  public void shouldDeleteRepositoryWhenUserHasProjectDeletionCapabilitiesAndNodeIsAReplica()
+      throws Exception {
+    String testProjectName = project.get();
+    url = getURL(testProjectName);
+    httpClientFactory
+        .create(source)
+        .execute(
+            createDeleteRequest(),
+            assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN),
+            getUserContext());
+
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .add(allowCapability(DELETE_PROJECT_PERMISSION).group(SystemGroupBackend.REGISTERED_USERS))
+        .update();
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            createDeleteRequest(),
+            assertHttpResponseCode(HttpServletResponse.SC_OK),
+            getUserContext());
+  }
+
+  @Test
+  @GerritConfig(name = "container.replica", value = "true")
+  public void shouldReturnInternalServerErrorIfProjectCannotBeDeletedWhenNodeIsAReplica()
+      throws Exception {
+    url = getURL(INVALID_TEST_PROJECT_NAME);
+
+    httpClientFactory
+        .create(source)
+        .execute(
+            createDeleteRequest(),
+            assertHttpResponseCode(HttpServletResponse.SC_INTERNAL_SERVER_ERROR),
+            getContext());
+  }
+
+  @Override
+  protected String getURL(String projectName) {
+    return String.format("%s/a/projects/%s", adminRestSession.url(), Url.encode(projectName));
+  }
+}