Merge remote-tracking branch 'origin/stable-3.3' into stable-3.4 * origin/stable-3.3: Allow project deletion on replicas Allow project creation only when CREATE_PROJECT capability is set Allow dynamic url creation in tests Handle HEAD update in replicas Trigger remote update HEAD Allow project deletion on primaries Change-Id: Ib912152066c56b9ea382c6bb737ad6d8d9e22a77
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java new file mode 100644 index 0000000..ed584b7 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/DeleteProjectTask.java
@@ -0,0 +1,79 @@ +// 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; + +import static com.googlesource.gerrit.plugins.replication.pull.ReplicationQueue.repLog; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.server.ioutil.HexFormat; +import com.google.gerrit.server.util.IdGenerator; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient; +import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult; +import java.io.IOException; +import java.net.URISyntaxException; +import org.eclipse.jgit.transport.URIish; + +public class DeleteProjectTask implements Runnable { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + interface Factory { + DeleteProjectTask create(Source source, String uri, Project.NameKey project); + } + + private final int id; + private final Source source; + private final String uri; + private final Project.NameKey project; + private final FetchRestApiClient.Factory fetchClientFactory; + + @Inject + DeleteProjectTask( + FetchRestApiClient.Factory fetchClientFactory, + IdGenerator ig, + @Assisted Source source, + @Assisted String uri, + @Assisted Project.NameKey project) { + this.fetchClientFactory = fetchClientFactory; + this.id = ig.next(); + this.uri = uri; + this.source = source; + this.project = project; + } + + @Override + public void run() { + try { + URIish urIish = new URIish(uri); + HttpResult httpResult = fetchClientFactory.create(source).deleteProject(project, urIish); + if (!httpResult.isSuccessful()) { + throw new IOException(httpResult.getMessage().orElse("Unknown")); + } + logger.atFine().log("Successfully deleted project {} on remote {}", project.get(), uri); + } catch (URISyntaxException | IOException e) { + String errorMessage = + String.format("Cannot delete project %s on remote site %s.", project, uri); + logger.atWarning().withCause(e).log(errorMessage); + repLog.warn(errorMessage); + } + } + + @Override + public String toString() { + return String.format("[%s] delete-project %s at %s", HexFormat.fromInt(id), project.get(), uri); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java new file mode 100644 index 0000000..2437d80 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/GerritConfigOps.java
@@ -0,0 +1,54 @@ +// 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; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SitePaths; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.Optional; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.transport.URIish; + +@Singleton +public class GerritConfigOps { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final SitePaths sitePath; + private final Config gerritConfig; + + @Inject + public GerritConfigOps(@GerritServerConfig Config cfg, SitePaths sitePath) { + this.sitePath = sitePath; + this.gerritConfig = cfg; + } + + public Optional<URIish> getGitRepositoryURI(String projectName) { + Path basePath = sitePath.resolve(gerritConfig.getString("gerrit", null, "basePath")); + URIish uri; + + try { + uri = new URIish("file://" + basePath + "/" + projectName); + return Optional.of(uri); + } catch (URISyntaxException e) { + logger.atSevere().withCause(e).log("Unsupported URI for project " + projectName); + } + + return Optional.empty(); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java index 50ab913..29ec93a 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationModule.java
@@ -20,7 +20,9 @@ import com.google.gerrit.extensions.annotations.Exports; import com.google.gerrit.extensions.config.CapabilityDefinition; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; +import com.google.gerrit.extensions.events.HeadUpdatedListener; import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicSet; import com.google.gerrit.server.config.SitePaths; import com.google.gerrit.server.events.EventTypes; @@ -101,6 +103,8 @@ bind(EventBus.class).in(Scopes.SINGLETON); bind(ReplicationSources.class).to(SourcesCollection.class); + DynamicSet.bind(binder(), ProjectDeletedListener.class).to(ReplicationQueue.class); + DynamicSet.bind(binder(), HeadUpdatedListener.class).to(ReplicationQueue.class); bind(ReplicationQueue.class).in(Scopes.SINGLETON); bind(ObservableQueue.class).to(ReplicationQueue.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java index 2a77e09..0e4ace1 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
@@ -19,7 +19,9 @@ import com.google.gerrit.entities.Project; import com.google.gerrit.entities.Project.NameKey; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; +import com.google.gerrit.extensions.events.HeadUpdatedListener; import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.server.events.EventDispatcher; import com.google.gerrit.server.git.WorkQueue; @@ -51,7 +53,11 @@ import org.slf4j.LoggerFactory; public class ReplicationQueue - implements ObservableQueue, LifecycleListener, GitReferenceUpdatedListener { + implements ObservableQueue, + LifecycleListener, + GitReferenceUpdatedListener, + ProjectDeletedListener, + HeadUpdatedListener { static final String PULL_REPLICATION_LOG_NAME = "pull_replication_log"; static final Logger repLog = LoggerFactory.getLogger(PULL_REPLICATION_LOG_NAME); @@ -131,6 +137,16 @@ } } + @Override + public void onProjectDeleted(ProjectDeletedListener.Event event) { + Project.NameKey project = Project.nameKey(event.getProjectName()); + sources.get().getAll().stream() + .filter((Source s) -> s.wouldDeleteProject(project)) + .forEach( + source -> + source.getApis().forEach(apiUrl -> source.scheduleDeleteProject(apiUrl, project))); + } + private Boolean isRefToBeReplicated(String refName) { return !refsFilter.match(refName); } @@ -298,6 +314,17 @@ } } + @Override + public void onHeadUpdated(HeadUpdatedListener.Event event) { + Project.NameKey p = Project.nameKey(event.getProjectName()); + sources.get().getAll().stream() + .filter(s -> s.wouldFetchProject(p)) + .forEach( + s -> + s.getApis() + .forEach(apiUrl -> s.scheduleUpdateHead(apiUrl, p, event.getNewHeadName()))); + } + @AutoValue abstract static class ReferenceUpdatedEvent {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java index 45713b8..28aa541 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/Source.java
@@ -70,6 +70,7 @@ import com.googlesource.gerrit.plugins.replication.pull.fetch.JGitFetch; import java.io.IOException; import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; import java.net.URLEncoder; import java.util.HashMap; import java.util.List; @@ -80,6 +81,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import org.apache.commons.io.FilenameUtils; @@ -101,6 +103,7 @@ } private final ReplicationStateListener stateLog; + private final UpdateHeadTask.Factory updateHeadFactory; private final Object stateLock = new Object(); private final Map<URIish, FetchOne> pending = new HashMap<>(); private final Map<URIish, FetchOne> inFlight = new HashMap<>(); @@ -114,6 +117,7 @@ private final SourceConfiguration config; private final DynamicItem<EventDispatcher> eventDispatcher; private CloseableHttpClient httpClient; + private final DeleteProjectTask.Factory deleteProjectFactory; protected enum RetryReason { TRANSPORT_ERROR, @@ -180,6 +184,7 @@ bind(Source.class).toInstance(Source.this); bind(SourceConfiguration.class).toInstance(config); install(new FactoryModuleBuilder().build(FetchOne.Factory.class)); + install(new FactoryModuleBuilder().build(DeleteProjectTask.Factory.class)); Class<? extends Fetch> clientClass = cfg.useCGitClient() ? CGitFetch.class : JGitFetch.class; install( @@ -187,6 +192,7 @@ .implement(Fetch.class, BatchFetchClient.class) .implement(Fetch.class, FetchClientImplementation.class, clientClass) .build(FetchFactory.class)); + factory(UpdateHeadTask.Factory.class); } @Provides @@ -210,6 +216,8 @@ child.getBinding(FetchFactory.class).acceptTargetVisitor(new CGitFetchValidator()); opFactory = child.getInstance(FetchOne.Factory.class); threadScoper = child.getInstance(PerThreadRequestScope.Scoper.class); + deleteProjectFactory = child.getInstance(DeleteProjectTask.Factory.class); + updateHeadFactory = child.getInstance(UpdateHeadTask.Factory.class); } public synchronized CloseableHttpClient memoize( @@ -445,6 +453,12 @@ } } + void scheduleDeleteProject(String uri, Project.NameKey project) { + @SuppressWarnings("unused") + ScheduledFuture<?> ignored = + pool.schedule(deleteProjectFactory.create(this, uri, project), 0, TimeUnit.SECONDS); + } + void fetchWasCanceled(FetchOne fetchOp) { synchronized (stateLock) { URIish uri = fetchOp.getURI(); @@ -592,11 +606,22 @@ return false; } + public boolean wouldDeleteProject(Project.NameKey project) { + if (isReplicateProjectDeletions()) { + return configSettingsAllowReplication(project); + } + return false; + } + public boolean wouldFetchProject(Project.NameKey project) { if (!shouldReplicate(project)) { return false; } + return configSettingsAllowReplication(project); + } + + private boolean configSettingsAllowReplication(Project.NameKey project) { // by default fetch all projects List<String> projects = config.getProjects(); if (projects.isEmpty()) { @@ -737,6 +762,23 @@ return config.createMissingRepositories(); } + public boolean isReplicateProjectDeletions() { + return config.replicateProjectDeletions(); + } + + void scheduleUpdateHead(String apiUrl, Project.NameKey project, String newHead) { + try { + URIish apiURI = new URIish(apiUrl); + @SuppressWarnings("unused") + ScheduledFuture<?> ignored = + pool.schedule( + updateHeadFactory.create(this, apiURI, project, newHead), 0, TimeUnit.SECONDS); + } catch (URISyntaxException e) { + logger.atSevere().withCause(e).log( + "Could not schedule HEAD pull-replication for project {}", project.get()); + } + } + private static boolean matches(URIish uri, String urlMatch) { if (urlMatch == null || urlMatch.equals("") || urlMatch.equals("*")) { return true;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java index 4858b17..ab9c634 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/SourceConfiguration.java
@@ -39,6 +39,7 @@ private final boolean replicatePermissions; private final boolean replicateHiddenProjects; private final boolean createMissingRepositories; + private final boolean replicateProjectDeletions; private final String remoteNameStyle; private final ImmutableList<String> urls; private final ImmutableList<String> projects; @@ -76,6 +77,7 @@ lockErrorMaxRetries = cfg.getInt("replication", "lockErrorMaxRetries", 0); createMissingRepositories = cfg.getBoolean("remote", name, "createMissingRepositories", true); + replicateProjectDeletions = cfg.getBoolean("remote", name, "replicateProjectDeletions", true); replicatePermissions = cfg.getBoolean("remote", name, "replicatePermissions", true); replicateHiddenProjects = cfg.getBoolean("remote", name, "replicateHiddenProjects", false); useCGitClient = cfg.getBoolean("replication", "useCGitClient", false); @@ -197,6 +199,10 @@ return createMissingRepositories; } + public boolean replicateProjectDeletions() { + return replicateProjectDeletions; + } + private static int getInt(RemoteConfig rc, Config cfg, String name, int defValue) { return cfg.getInt("remote", rc.getName(), name, defValue); }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java new file mode 100644 index 0000000..943ea92 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/UpdateHeadTask.java
@@ -0,0 +1,87 @@ +// 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; + +import static com.googlesource.gerrit.plugins.replication.pull.ReplicationQueue.repLog; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.server.ioutil.HexFormat; +import com.google.gerrit.server.util.IdGenerator; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.googlesource.gerrit.plugins.replication.pull.client.FetchRestApiClient; +import com.googlesource.gerrit.plugins.replication.pull.client.HttpResult; +import java.io.IOException; +import org.eclipse.jgit.transport.URIish; + +public class UpdateHeadTask implements Runnable { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final FetchRestApiClient.Factory fetchClientFactory; + private final Source source; + private final URIish apiURI; + private final Project.NameKey project; + private final String newHead; + private final int id; + + interface Factory { + UpdateHeadTask create(Source source, URIish apiURI, Project.NameKey project, String newHead); + } + + @Inject + UpdateHeadTask( + FetchRestApiClient.Factory fetchClientFactory, + IdGenerator ig, + @Assisted Source source, + @Assisted URIish apiURI, + @Assisted Project.NameKey project, + @Assisted String newHead) { + this.fetchClientFactory = fetchClientFactory; + this.id = ig.next(); + this.source = source; + this.apiURI = apiURI; + this.project = project; + this.newHead = newHead; + } + + @Override + public void run() { + try { + HttpResult httpResult = + fetchClientFactory.create(source).updateHead(project, newHead, apiURI); + if (!httpResult.isSuccessful()) { + throw new IOException(httpResult.getMessage().orElse("Unknown")); + } + logger.atFine().log( + "Successfully updated HEAD of project {} on remote {}", + project.get(), + apiURI.toASCIIString()); + } catch (IOException e) { + String errorMessage = + String.format( + "Cannot update HEAD of project %s remote site %s", + project.get(), apiURI.toASCIIString()); + logger.atWarning().withCause(e).log(errorMessage); + repLog.warn(errorMessage); + } + } + + @Override + public String toString() { + return String.format( + "[%s] update-head %s at %s to %s", + HexFormat.fromInt(id), project.get(), apiURI.toASCIIString(), newHead); + } +}
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/ProjectInitializationAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java index 8f5c9d0..01635e6 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
@@ -21,23 +21,23 @@ import com.google.common.flogger.FluentLogger; import com.google.gerrit.entities.Project; import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.Url; import com.google.gerrit.server.CurrentUser; -import com.google.gerrit.server.config.GerritServerConfig; -import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.permissions.GlobalPermission; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.inject.Inject; import com.google.inject.Provider; import com.google.inject.Singleton; import com.googlesource.gerrit.plugins.replication.LocalFS; +import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps; import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Path; import java.util.Optional; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.transport.URIish; @Singleton @@ -47,16 +47,18 @@ public static final String PROJECT_NAME = "project-name"; - private final SitePaths sitePath; - private final Config gerritConfig; + private final GerritConfigOps gerritConfigOps; private final Provider<CurrentUser> userProvider; + private final PermissionBackend permissionBackend; @Inject ProjectInitializationAction( - @GerritServerConfig Config cfg, SitePaths sitePath, Provider<CurrentUser> userProvider) { - this.sitePath = sitePath; - this.gerritConfig = cfg; + GerritConfigOps gerritConfigOps, + Provider<CurrentUser> userProvider, + PermissionBackend permissionBackend) { + this.gerritConfigOps = gerritConfigOps; this.userProvider = userProvider; + this.permissionBackend = permissionBackend; } @Override @@ -79,11 +81,19 @@ String path = httpServletRequest.getRequestURI(); String projectName = Url.decode(path.substring(path.lastIndexOf('/') + 1)); - if (initProject(projectName)) { + try { + if (initProject(projectName)) { + setResponse( + httpServletResponse, + HttpServletResponse.SC_CREATED, + "Project " + projectName + " initialized"); + return; + } + } catch (AuthException | PermissionBackendException e) { setResponse( httpServletResponse, - HttpServletResponse.SC_CREATED, - "Project " + projectName + " initialized"); + HttpServletResponse.SC_FORBIDDEN, + "User not authorized to create project " + projectName); return; } @@ -93,8 +103,11 @@ "Cannot initialize project " + projectName); } - protected boolean initProject(String projectName) { - Optional<URIish> maybeUri = getGitRepositoryURI(projectName); + protected boolean initProject(String projectName) + throws AuthException, PermissionBackendException { + permissionBackend.user(userProvider.get()).check(GlobalPermission.CREATE_PROJECT); + + Optional<URIish> maybeUri = gerritConfigOps.getGitRepositoryURI(projectName); if (!maybeUri.isPresent()) { logger.atSevere().log("Cannot initialize project '{}'", projectName); return false; @@ -104,20 +117,6 @@ return localFS.createProject(projectNameKey, RefNames.HEAD); } - private Optional<URIish> getGitRepositoryURI(String projectName) { - Path basePath = sitePath.resolve(gerritConfig.getString("gerrit", null, "basePath")); - URIish uri; - - try { - uri = new URIish("file://" + basePath + "/" + projectName); - return Optional.of(uri); - } catch (URISyntaxException e) { - logger.atSevere().withCause(e).log("Unsupported URI for project " + projectName); - } - - return Optional.empty(); - } - public static String getProjectInitializationUrl(String pluginName, String projectName) { return String.format( "a/plugins/%s/init-project/%s", pluginName, Url.encode(projectName) + ".git");
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 1425be6..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
@@ -27,6 +27,7 @@ import com.google.common.base.Splitter; import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.api.projects.HeadInput; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.IdString; @@ -74,6 +75,8 @@ private FetchAction fetchAction; private ApplyObjectAction applyObjectAction; private ProjectInitializationAction projectInitializationAction; + private UpdateHeadAction updateHEADAction; + private ProjectDeletionAction projectDeletionAction; private ProjectsCollection projectsCollection; private Gson gson; private Provider<CurrentUser> userProvider; @@ -83,11 +86,15 @@ FetchAction fetchAction, 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(); @@ -125,6 +132,18 @@ } else { httpResponse.sendError(SC_UNAUTHORIZED); } + } else if (isUpdateHEADAction(httpRequest)) { + if (userProvider.get().isIdentifiedUser()) { + writeResponse(httpResponse, doUpdateHEAD(httpRequest)); + } 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); } @@ -153,7 +172,7 @@ } private void doInitProject(HttpServletRequest httpRequest, HttpServletResponse httpResponse) - throws RestApiException, IOException { + throws RestApiException, IOException, PermissionBackendException { String path = httpRequest.getRequestURI(); String projectName = Url.decode(path.substring(path.lastIndexOf('/') + 1)); @@ -176,6 +195,23 @@ } @SuppressWarnings("unchecked") + private Response<String> doUpdateHEAD(HttpServletRequest httpRequest) throws Exception { + HeadInput input = readJson(httpRequest, TypeLiteral.get(HeadInput.class)); + ProjectResource projectResource = + projectsCollection.parse(TopLevelResource.INSTANCE, getProjectName(httpRequest)); + + return (Response<String>) updateHEADAction.apply(projectResource, input); + } + + @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)); @@ -185,8 +221,8 @@ return (Response<Map<String, Object>>) fetchAction.apply(projectResource, input); } - private void writeResponse( - HttpServletResponse httpResponse, Response<Map<String, Object>> response) throws IOException { + private <T> void writeResponse(HttpServletResponse httpResponse, Response<T> response) + throws IOException { String responseJson = gson.toJson(response); if (response.statusCode() == SC_OK || response.statusCode() == SC_CREATED) { @@ -260,4 +296,14 @@ private boolean isInitProjectAction(HttpServletRequest httpRequest) { return httpRequest.getRequestURI().contains("pull-replication/init-project/"); } + + private boolean isUpdateHEADAction(HttpServletRequest httpRequest) { + 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/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java new file mode 100644 index 0000000..4195435 --- /dev/null +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java
@@ -0,0 +1,79 @@ +// 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.common.base.Strings; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.api.projects.HeadInput; +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.permissions.RefPermission; +import com.google.gerrit.server.project.ProjectResource; +import com.google.inject.Inject; +import com.google.inject.Singleton; +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; + +@Singleton +public class UpdateHeadAction implements RestModifyView<ProjectResource, HeadInput> { + private final GerritConfigOps gerritConfigOps; + private final PermissionBackend permissionBackend; + + @Inject + UpdateHeadAction(GerritConfigOps gerritConfigOps, PermissionBackend permissionBackend) { + this.gerritConfigOps = gerritConfigOps; + this.permissionBackend = permissionBackend; + } + + @Override + public Response<?> apply(ProjectResource projectResource, HeadInput input) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + if (input == null || Strings.isNullOrEmpty(input.ref)) { + throw new BadRequestException("ref required"); + } + String ref = RefNames.fullName(input.ref); + + permissionBackend + .user(projectResource.getUser()) + .project(projectResource.getNameKey()) + .ref(ref) + .check(RefPermission.SET_HEAD); + + // TODO: the .git suffix should not be added here, but rather it should be + // dealt with by the caller, honouring the naming style from the + // replication.config (Issue 15221) + Optional<URIish> maybeRepo = + gerritConfigOps.getGitRepositoryURI(String.format("%s.git", projectResource.getName())); + + if (maybeRepo.isPresent()) { + if (new LocalFS(maybeRepo.get()).updateHead(projectResource.getNameKey(), ref)) { + return Response.ok(ref); + } + throw new UnprocessableEntityException( + String.format( + "Could not update HEAD of repo %s to ref %s", projectResource.getName(), ref)); + } + throw new ResourceNotFoundException( + String.format("Could not compute URL for repo: %s", projectResource.getName())); + } +}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java index 811d064..df97609 100644 --- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java +++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClient.java
@@ -44,6 +44,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; @@ -121,6 +122,25 @@ return httpClientFactory.create(source).execute(put, this, getContext(uri)); } + public HttpResult deleteProject(Project.NameKey project, URIish apiUri) throws IOException { + String url = + String.format("%s/%s", apiUri.toASCIIString(), getProjectDeletionUrl(project.get())); + HttpDelete delete = new HttpDelete(url); + return httpClientFactory.create(source).execute(delete, this, getContext(apiUri)); + } + + public HttpResult updateHead(Project.NameKey project, String newHead, URIish apiUri) + throws IOException { + logger.atFine().log("Updating head of %s on %s", project.get(), newHead); + String url = + String.format("%s/%s", apiUri.toASCIIString(), getProjectUpdateHeadUrl(project.get())); + HttpPut req = new HttpPut(url); + req.setEntity( + new StringEntity(String.format("{\"ref\": \"%s\"}", newHead), StandardCharsets.UTF_8)); + req.addHeader(new BasicHeader("Content-Type", MediaType.JSON_UTF_8.toString())); + return httpClientFactory.create(source).execute(req, this, getContext(apiUri)); + } + public HttpResult callSendObject( Project.NameKey project, String refName, RevisionData revisionData, URIish targetUri) throws ClientProtocolException, IOException { @@ -168,4 +188,12 @@ } return null; } + + String getProjectDeletionUrl(String projectName) { + return String.format("a/projects/%s", Url.encode(projectName)); + } + + String getProjectUpdateHeadUrl(String projectName) { + return String.format("a/projects/%s/HEAD", Url.encode(projectName)); + } }
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md index f2a596b..68c9001 100644 --- a/src/main/resources/Documentation/config.md +++ b/src/main/resources/Documentation/config.md
@@ -354,6 +354,12 @@ By default, true. +remote.NAME.replicateProjectDeletions +: If true, project deletions will also be replicated to the +remote site. + + By default, false, do *not* replicate project deletions. + remote.NAME.authGroup : Specifies the name of a group that the remote should use to access the repositories. Multiple authGroups may be specified
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeHeadUpdateEvent.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeHeadUpdateEvent.java new file mode 100644 index 0000000..d1769ab --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/FakeHeadUpdateEvent.java
@@ -0,0 +1,52 @@ +// 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; + +import com.google.gerrit.extensions.api.changes.NotifyHandling; +import com.google.gerrit.extensions.events.HeadUpdatedListener; + +class FakeHeadUpdateEvent implements HeadUpdatedListener.Event { + + private final String oldName; + private final String newName; + private final String projectName; + + FakeHeadUpdateEvent(String oldName, String newName, String projectName) { + + this.oldName = oldName; + this.newName = newName; + this.projectName = projectName; + } + + @Override + public NotifyHandling getNotify() { + return NotifyHandling.NONE; + } + + @Override + public String getOldHeadName() { + return oldName; + } + + @Override + public String getNewHeadName() { + return newName; + } + + @Override + public String getProjectName() { + return projectName; + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java index 9557025..c2335e8 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/PullReplicationIT.java
@@ -15,6 +15,7 @@ package com.googlesource.gerrit.plugins.replication.pull; import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND; import static java.util.stream.Collectors.toList; import com.google.common.flogger.FluentLogger; @@ -25,10 +26,25 @@ import com.google.gerrit.acceptance.UseLocalDisk; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.api.changes.NotifyHandling; import com.google.gerrit.extensions.api.projects.BranchInput; +import com.google.gerrit.extensions.common.Input; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; +import com.google.gerrit.extensions.events.HeadUpdatedListener; +import com.google.gerrit.extensions.events.ProjectDeletedListener; +import com.google.gerrit.extensions.registration.DynamicSet; +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.Response; +import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.extensions.restapi.RestApiModule; +import com.google.gerrit.extensions.restapi.RestModifyView; import com.google.gerrit.server.config.SitePaths; +import com.google.gerrit.server.project.ProjectResource; +import com.google.inject.AbstractModule; import com.google.inject.Inject; +import com.google.inject.Singleton; import com.googlesource.gerrit.plugins.replication.AutoReloadConfigDecorator; import java.io.IOException; import java.nio.file.Path; @@ -62,14 +78,33 @@ @Inject private SitePaths sitePaths; @Inject private ProjectOperations projectOperations; + @Inject private DynamicSet<ProjectDeletedListener> deletedListeners; private Path gitPath; private FileBasedConfig config; private FileBasedConfig secureConfig; + static FakeDeleteProjectPlugin fakeDeleteProjectPlugin; + + static class FakeDeleteModule extends AbstractModule { + + @Override + public void configure() { + install( + new RestApiModule() { + @Override + public void configure() { + fakeDeleteProjectPlugin = new FakeDeleteProjectPlugin(); + delete(PROJECT_KIND).toInstance(fakeDeleteProjectPlugin); + } + }); + } + } + @Override public void setUpTestPlugin() throws Exception { gitPath = sitePaths.site_path.resolve("git"); + installPlugin("fakeDeleteProjectPlugin", FakeDeleteModule.class, null, null); config = new FileBasedConfig(sitePaths.etc_dir.resolve("replication.config").toFile(), FS.DETECTED); setReplicationSource( @@ -222,6 +257,67 @@ } } + @Test + public void shouldReplicateProjectDeletion() throws Exception { + String projectToDelete = project.get(); + setReplicationSource(TEST_REPLICATION_REMOTE, "", Optional.of(projectToDelete)); + config.save(); + AutoReloadConfigDecorator autoReloadConfigDecorator = + getInstance(AutoReloadConfigDecorator.class); + autoReloadConfigDecorator.reload(); + + ProjectDeletedListener.Event event = + new ProjectDeletedListener.Event() { + @Override + public String getProjectName() { + return projectToDelete; + } + + @Override + public NotifyHandling getNotify() { + return NotifyHandling.NONE; + } + }; + + for (ProjectDeletedListener l : deletedListeners) { + l.onProjectDeleted(event); + } + + waitUntil(() -> fakeDeleteProjectPlugin.getDeleteEndpointCalls() == 1); + } + + @Test + public void shouldReplicateHeadUpdate() throws Exception { + String testProjectName = project.get(); + setReplicationSource(TEST_REPLICATION_REMOTE, "", Optional.of(testProjectName)); + config.save(); + AutoReloadConfigDecorator autoReloadConfigDecorator = + getInstance(AutoReloadConfigDecorator.class); + autoReloadConfigDecorator.reload(); + + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + String branchRevision = gApi.projects().name(testProjectName).branch(newBranch).get().revision; + + ReplicationQueue pullReplicationQueue = + plugin.getSysInjector().getInstance(ReplicationQueue.class); + + HeadUpdatedListener.Event event = new FakeHeadUpdateEvent(master, newBranch, testProjectName); + pullReplicationQueue.onHeadUpdated(event); + + waitUntil( + () -> { + try { + return gApi.projects().name(testProjectName).head().equals(newBranch); + } catch (RestApiException e) { + return false; + } + }); + } + private Ref getRef(Repository repo, String branchName) throws IOException { return repo.getRefDatabase().exactRef(branchName); } @@ -278,4 +374,24 @@ private Project.NameKey createTestProject(String name) throws Exception { return projectOperations.newProject().name(name).create(); } + + @Singleton + public static class FakeDeleteProjectPlugin implements RestModifyView<ProjectResource, Input> { + private int deleteEndpointCalls; + + FakeDeleteProjectPlugin() { + this.deleteEndpointCalls = 0; + } + + @Override + public Response<?> apply(ProjectResource resource, Input input) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + deleteEndpointCalls += 1; + return Response.ok(); + } + + int getDeleteEndpointCalls() { + return deleteEndpointCalls; + } + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java index 286fe24..78eb3f7 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueueTest.java
@@ -14,20 +14,24 @@ package com.googlesource.gerrit.plugins.replication.pull; +import static com.google.common.truth.Truth.assertThat; import static java.nio.file.Files.createTempDirectory; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyZeroInteractions; import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; +import com.google.gerrit.entities.Project; import com.google.gerrit.extensions.api.changes.NotifyHandling; import com.google.gerrit.extensions.common.AccountInfo; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener; import com.google.gerrit.extensions.events.GitReferenceUpdatedListener.Event; +import com.google.gerrit.extensions.events.ProjectDeletedListener; import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.server.config.SitePaths; import com.google.gerrit.server.events.EventDispatcher; @@ -51,6 +55,8 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; @@ -71,6 +77,9 @@ @Mock RevisionData revisionData; @Mock HttpResult httpResult; + @Captor ArgumentCaptor<String> stringCaptor; + @Captor ArgumentCaptor<Project.NameKey> projectNameKeyCaptor; + private ExcludedRefsFilter refsFilter; private ReplicationQueue objectUnderTest; private SitePaths sitePaths; @@ -261,6 +270,62 @@ verifyZeroInteractions(wq, rd, dis, sl, fetchClientFactory, accountInfo); } + @Test + public void shouldCallDeleteWhenReplicateProjectDeletionsTrue() throws IOException { + when(source.wouldDeleteProject(any())).thenReturn(true); + + String projectName = "testProject"; + FakeProjectDeletedEvent event = new FakeProjectDeletedEvent(projectName); + + objectUnderTest.start(); + objectUnderTest.onProjectDeleted(event); + + verify(source, times(1)) + .scheduleDeleteProject(stringCaptor.capture(), projectNameKeyCaptor.capture()); + assertThat(stringCaptor.getValue()).isEqualTo(source.getApis().get(0)); + assertThat(projectNameKeyCaptor.getValue()).isEqualTo(Project.NameKey.parse(projectName)); + } + + @Test + public void shouldNotCallDeleteWhenProjectNotToDelete() throws IOException { + when(source.wouldDeleteProject(any())).thenReturn(false); + + FakeProjectDeletedEvent event = new FakeProjectDeletedEvent("testProject"); + + objectUnderTest.start(); + objectUnderTest.onProjectDeleted(event); + + verify(source, never()).scheduleDeleteProject(any(), any()); + } + + @Test + public void shouldScheduleUpdateHeadWhenWouldFetchProject() throws IOException { + when(source.wouldFetchProject(any())).thenReturn(true); + + String projectName = "aProject"; + String newHEAD = "newHEAD"; + + objectUnderTest.start(); + objectUnderTest.onHeadUpdated(new FakeHeadUpdateEvent("oldHead", newHEAD, projectName)); + verify(source, times(1)) + .scheduleUpdateHead(any(), projectNameKeyCaptor.capture(), stringCaptor.capture()); + + assertThat(stringCaptor.getValue()).isEqualTo(newHEAD); + assertThat(projectNameKeyCaptor.getValue()).isEqualTo(Project.NameKey.parse(projectName)); + } + + @Test + public void shouldNotScheduleUpdateHeadWhenNotWouldFetchProject() throws IOException { + when(source.wouldFetchProject(any())).thenReturn(false); + + String projectName = "aProject"; + String newHEAD = "newHEAD"; + + objectUnderTest.start(); + objectUnderTest.onHeadUpdated(new FakeHeadUpdateEvent("oldHead", newHEAD, projectName)); + verify(source, never()).scheduleUpdateHead(any(), any(), any()); + } + protected static Path createTempPath(String prefix) throws IOException { return createTempDirectory(prefix); } @@ -325,4 +390,22 @@ return null; } } + + private class FakeProjectDeletedEvent implements ProjectDeletedListener.Event { + private String projectName; + + public FakeProjectDeletedEvent(String projectName) { + this.projectName = projectName; + } + + @Override + public NotifyHandling getNotify() { + return null; + } + + @Override + public String getProjectName() { + return projectName; + } + } }
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 49cf608..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
@@ -20,6 +20,7 @@ import com.google.gerrit.acceptance.LightweightPluginDaemonTest; import com.google.gerrit.acceptance.PushOneCommit.Result; import com.google.gerrit.acceptance.SkipProjectClone; +import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.TestPlugin; import com.google.gerrit.acceptance.UseLocalDisk; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; @@ -47,7 +48,9 @@ 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; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; @@ -79,7 +82,7 @@ SourceHttpClient.Factory httpClientFactory; String url; - protected abstract String getURL(); + protected abstract String getURL(String projectName); @Override public void setUpTestPlugin() throws Exception { @@ -105,7 +108,7 @@ revisionReader = plugin.getSysInjector().getInstance(RevisionReader.class); source = plugin.getSysInjector().getInstance(SourcesCollection.class).getAll().get(0); - url = getURL(); + url = getURL(project.get()); } protected HttpPost createRequest(String sendObjectPayload) { @@ -115,6 +118,19 @@ return post; } + protected HttpPut createPutRequest(String sendObjectPayload) { + HttpPut put = new HttpPut(url); + put.setEntity(new StringEntity(sendObjectPayload, StandardCharsets.UTF_8)); + put.addHeader(new BasicHeader("Content-Type", "application/json")); + 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)); } @@ -152,12 +168,11 @@ } protected HttpClientContext getContext() { - HttpClientContext ctx = HttpClientContext.create(); - CredentialsProvider adapted = new BasicCredentialsProvider(); - adapted.setCredentials( - AuthScope.ANY, new UsernamePasswordCredentials(admin.username(), admin.httpPassword())); - ctx.setCredentialsProvider(adapted); - return ctx; + return getContextForAccount(admin); + } + + protected HttpClientContext getUserContext() { + return getContextForAccount(user); } protected HttpClientContext getAnonymousContext() { @@ -199,4 +214,13 @@ secureConfig.setString("remote", remoteName, "password", password); secureConfig.save(); } + + private HttpClientContext getContextForAccount(TestAccount account) { + HttpClientContext ctx = HttpClientContext.create(); + CredentialsProvider adapted = new BasicCredentialsProvider(); + adapted.setCredentials( + AuthScope.ANY, new UsernamePasswordCredentials(account.username(), account.httpPassword())); + ctx.setCredentialsProvider(adapted); + return ctx; + } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java index ec172ad..aad3903 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ApplyObjectActionIT.java
@@ -176,9 +176,9 @@ } @Override - protected String getURL() { + protected String getURL(String projectName) { return String.format( "%s/a/projects/%s/pull-replication~apply-object", - adminRestSession.url(), Url.encode(project.get())); + adminRestSession.url(), Url.encode(projectName)); } }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java index 7b73df7..4ad8ce6 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/FetchActionIT.java
@@ -76,9 +76,8 @@ } @Override - protected String getURL() { + protected String getURL(String projectName) { return String.format( - "%s/a/projects/%s/pull-replication~fetch", - adminRestSession.url(), Url.encode(project.get())); + "%s/a/projects/%s/pull-replication~fetch", adminRestSession.url(), Url.encode(projectName)); } }
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)); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java index 919ac34..ca4fa31 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationActionIT.java
@@ -14,11 +14,16 @@ package com.googlesource.gerrit.plugins.replication.pull.api; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability; import static com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction.getProjectInitializationUrl; import com.google.common.net.MediaType; import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.common.data.GlobalCapability; 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.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPut; @@ -27,7 +32,7 @@ public class ProjectInitializationActionIT extends ActionITBase { public static final String INVALID_TEST_PROJECT_NAME = "\0"; - private String testProjectName = "new/Project"; + @Inject private ProjectOperations projectOperations; @Test public void shouldReturnUnauthorizedForUserWithoutPermissions() throws Exception { @@ -51,6 +56,8 @@ @Test public void shouldCreateRepository() throws Exception { + String newProjectName = "new/newProjectForPrimary"; + url = getURL(newProjectName); httpClientFactory .create(source) .execute( @@ -59,7 +66,7 @@ getContext()); HttpGet getNewProjectRequest = - new HttpGet(userRestSession.url() + "/a/projects/" + Url.encode("new/Project")); + new HttpGet(userRestSession.url() + "/a/projects/" + Url.encode(newProjectName)); httpClientFactory .create(source) .execute( @@ -67,8 +74,47 @@ } @Test + public void shouldCreateRepositoryWhenUserHasProjectCreationCapabilities() throws Exception { + String newProjectName = "new/newProjectForUserWithCapabilities"; + url = getURL(newProjectName); + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(allProjects) + .forUpdate() + .add( + allowCapability(GlobalCapability.CREATE_PROJECT) + .group(SystemGroupBackend.REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_CREATED), + getUserContext()); + } + + @Test + public void shouldReturnForbiddenIfUserNotAuthorized() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + @Test @GerritConfig(name = "container.replica", value = "true") public void shouldCreateRepositoryWhenNodeIsAReplica() throws Exception { + String newProjectName = "new/newProjectForReplica"; + url = getURL(newProjectName); httpClientFactory .create(source) .execute( @@ -79,10 +125,49 @@ @Test @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnForbiddenIfUserNotAuthorizedAndNodeIsAReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldCreateRepositoryWhenUserHasProjectCreationCapabilitiesAndNodeIsAReplica() + throws Exception { + String newProjectName = "new/newProjectForUserWithCapabilitiesReplica"; + url = getURL(newProjectName); + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(allProjects) + .forUpdate() + .add( + allowCapability(GlobalCapability.CREATE_PROJECT) + .group(SystemGroupBackend.REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createPutRequestWithHeaders(), + assertHttpResponseCode(HttpServletResponse.SC_CREATED), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") public void shouldReturnInternalServerErrorIfProjectCannotBeCreatedWhenNodeIsAReplica() throws Exception { - testProjectName = INVALID_TEST_PROJECT_NAME; - url = getURL(); + url = getURL(INVALID_TEST_PROJECT_NAME); httpClientFactory .create(source) @@ -116,10 +201,10 @@ } @Override - protected String getURL() { + protected String getURL(String projectName) { return userRestSession.url() + "/" - + getProjectInitializationUrl("pull-replication", Url.encode(testProjectName)); + + getProjectInitializationUrl("pull-replication", Url.encode(projectName)); } protected HttpPut createPutRequestWithHeaders() {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java new file mode 100644 index 0000000..aa07a7c --- /dev/null +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadActionIT.java
@@ -0,0 +1,167 @@ +// 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.common.truth.Truth.assertThat; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; + +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.entities.Permission; +import com.google.gerrit.extensions.api.projects.BranchInput; +import com.google.gerrit.extensions.api.projects.HeadInput; +import com.google.gson.Gson; +import com.google.inject.Inject; +import javax.servlet.http.HttpServletResponse; +import org.junit.Test; + +public class UpdateHeadActionIT extends ActionITBase { + private static final Gson gson = newGson(); + + @Inject private ProjectOperations projectOperations; + + @Test + public void shouldReturnUnauthorizedForUserWithoutPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/branch")), + assertHttpResponseCode(HttpServletResponse.SC_UNAUTHORIZED), + getAnonymousContext()); + } + + @Test + public void shouldReturnBadRequestWhenInputIsEmpty() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("")), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + public void shouldReturnOKWhenHeadIsUpdated() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getContext()); + + assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnBadRequestWhenInputIsEmptyInReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("")), + assertHttpResponseCode(HttpServletResponse.SC_BAD_REQUEST), + getContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnOKWhenHeadIsUpdatedInReplica() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getContext()); + + assertThat(gApi.projects().name(testProjectName).head()).isEqualTo(newBranch); + } + + @Test + public void shouldReturnForbiddenWhenMissingPermissions() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/new/head")), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + @Test + public void shouldReturnOKWhenRegisteredUserHasPermissions() throws Exception { + String testProjectName = project.get(); + String newBranch = "refs/heads/mybranch"; + String master = "refs/heads/master"; + BranchInput input = new BranchInput(); + input.revision = master; + gApi.projects().name(testProjectName).branch(newBranch).create(input); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS)) + .update(); + + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput(newBranch)), + assertHttpResponseCode(HttpServletResponse.SC_OK), + getUserContext()); + } + + @Test + @GerritConfig(name = "container.replica", value = "true") + public void shouldReturnForbiddenWhenMissingPermissionsInReplica() throws Exception { + httpClientFactory + .create(source) + .execute( + createPutRequest(headInput("some/new/head")), + assertHttpResponseCode(HttpServletResponse.SC_FORBIDDEN), + getUserContext()); + } + + private String headInput(String ref) { + HeadInput headInput = new HeadInput(); + headInput.ref = ref; + return gson.toJson(headInput); + } + + @Override + protected String getURL(String projectName) { + return String.format("%s/a/projects/%s/HEAD", adminRestSession.url(), projectName); + } +}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java index 39a8f2d..ed1eef4 100644 --- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java +++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/client/FetchRestApiClientTest.java
@@ -23,7 +23,9 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.common.base.Charsets; import com.google.common.collect.Lists; +import com.google.common.io.CharStreams; import com.google.gerrit.entities.Project; import com.google.gerrit.entities.RefNames; import com.googlesource.gerrit.plugins.replication.CredentialsFactory; @@ -33,11 +35,13 @@ import com.googlesource.gerrit.plugins.replication.pull.api.data.RevisionObjectData; import com.googlesource.gerrit.plugins.replication.pull.filter.SyncRefsFilter; import java.io.IOException; +import java.io.InputStreamReader; import java.net.URISyntaxException; import java.nio.ByteBuffer; import java.util.Optional; import org.apache.http.Header; import org.apache.http.client.ClientProtocolException; +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.message.BasicHeader; @@ -69,6 +73,7 @@ @Mock Source source; @Captor ArgumentCaptor<HttpPost> httpPostCaptor; @Captor ArgumentCaptor<HttpPut> httpPutCaptor; + @Captor ArgumentCaptor<HttpDelete> httpDeleteCaptor; String api = "http://gerrit-host"; String pluginName = "pull-replication"; @@ -362,6 +367,37 @@ .isEqualTo("/a/plugins/pull-replication/init-project/test_repo.git"); } + @Test + public void shouldCallDeleteProjectEndpoint() throws IOException, URISyntaxException { + + objectUnderTest.deleteProject(Project.nameKey("test_repo"), new URIish(api)); + + verify(httpClient, times(1)).execute(httpDeleteCaptor.capture(), any(), any()); + + HttpDelete httpDelete = httpDeleteCaptor.getValue(); + assertThat(httpDelete.getURI().getHost()).isEqualTo("gerrit-host"); + assertThat(httpDelete.getURI().getPath()).isEqualTo("/a/projects/test_repo"); + } + + @Test + public void shouldCallUpdateHEADEndpoint() throws IOException, URISyntaxException { + String newHead = "newHead"; + String projectName = "aProject"; + objectUnderTest.updateHead(Project.nameKey(projectName), newHead, new URIish(api)); + + verify(httpClient, times(1)).execute(httpPutCaptor.capture(), any(), any()); + + HttpPut httpPut = httpPutCaptor.getValue(); + String payload = + CharStreams.toString( + new InputStreamReader(httpPut.getEntity().getContent(), Charsets.UTF_8)); + + assertThat(httpPut.getURI().getHost()).isEqualTo("gerrit-host"); + assertThat(httpPut.getURI().getPath()) + .isEqualTo(String.format("/a/projects/%s/HEAD", projectName)); + assertThat(payload).isEqualTo(String.format("{\"ref\": \"%s\"}", newHead)); + } + public String readPayload(HttpPost entity) throws UnsupportedOperationException, IOException { ByteBuffer buf = IO.readWholeStream(entity.getEntity().getContent(), 1024); return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim();