blob: 97fcf0e1bbdaf03bd9c671e9d82521c4c31d90b7 [file] [log] [blame]
// Copyright (C) 2024 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.google.gerrit.acceptance;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH;
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.PluginPushOption;
import com.google.gerrit.server.ValidationOptionsListener;
import com.google.gerrit.server.change.EmailReviewComments;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.flow.Flow;
import com.google.gerrit.server.flow.FlowCreation;
import com.google.gerrit.server.flow.FlowExpression;
import com.google.gerrit.server.flow.FlowKey;
import com.google.gerrit.server.flow.FlowPermissionDeniedException;
import com.google.gerrit.server.flow.FlowService;
import com.google.gerrit.server.flow.FlowStage;
import com.google.gerrit.server.flow.FlowStageEvaluationStatus;
import com.google.gerrit.server.flow.InvalidFlowException;
import com.google.gerrit.server.git.validators.CommitValidationException;
import com.google.gerrit.server.git.validators.CommitValidationInfo;
import com.google.gerrit.server.git.validators.CommitValidationInfoListener;
import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.rules.SubmitRule;
import com.google.gerrit.server.update.RetryListener;
import com.google.gerrit.server.update.context.RefUpdateContext;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Class to host common test extension implementations.
*
* <p>To test the invocation of an extension point tests usually register a test implementation for
* the extension that records the parameters with which it has been called.
*
* <p>If the same extension point is triggered by different actions, these test extension
* implementations may be needed in different test classes. To avoid duplicating them in the test
* classes, they can be added to this class and then be reused from the different tests.
*/
public class TestExtensions {
public static class TestCommitValidationListener implements CommitValidationListener {
public CommitReceivedEvent receiveEvent;
@Override
public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
throws CommitValidationException {
this.receiveEvent = receiveEvent;
return ImmutableList.of();
}
}
public static class TestValidationOptionsListener implements ValidationOptionsListener {
public ImmutableListMultimap<String, String> validationOptions;
@Override
public void onPatchSetCreation(
BranchNameKey projectAndBranch,
PatchSet.Id patchSetId,
ImmutableListMultimap<String, String> validationOptions) {
this.validationOptions = validationOptions;
}
}
public static class TestCommitValidationInfoListener implements CommitValidationInfoListener {
public ImmutableMap<String, CommitValidationInfo> validationInfoByValidator;
public CommitReceivedEvent receiveEvent;
@Nullable public PatchSet.Id patchSetId;
public boolean hasChangeModificationRefContext;
public boolean hasDirectPushRefContext;
@Override
public void commitValidated(
ImmutableMap<String, CommitValidationInfo> validationInfoByValidator,
CommitReceivedEvent receiveEvent,
PatchSet.Id patchSetId) {
this.validationInfoByValidator = validationInfoByValidator;
this.receiveEvent = receiveEvent;
this.patchSetId = patchSetId;
this.hasChangeModificationRefContext = RefUpdateContext.hasOpen(CHANGE_MODIFICATION);
this.hasDirectPushRefContext = RefUpdateContext.hasOpen(DIRECT_PUSH);
}
}
public static class TestPluginPushOption implements PluginPushOption {
private final String name;
private final String description;
private final Boolean enabled;
public TestPluginPushOption(String name, String description, Boolean enabled) {
this.name = name;
this.description = description;
this.enabled = enabled;
}
@Override
public String getName() {
return name;
}
@Override
public String getDescription() {
return description;
}
@Override
public boolean isOptionEnabled(ChangeNotes changeNotes) {
return enabled;
}
@Override
public boolean isOptionEnabled(Project.NameKey project, BranchNameKey branch) {
return enabled;
}
}
public static class TestRetryListener implements RetryListener {
private List<Retry> retries = new ArrayList<>();
@Override
public void onRetry(String actionType, String actionName, long nextAttempt, Throwable cause) {
this.retries.add(new Retry(actionType, actionName, nextAttempt, cause));
}
public ImmutableList<Retry> getRetries() {
return ImmutableList.copyOf(retries);
}
public Retry getOnlyRetry() {
return Iterables.getOnlyElement(retries);
}
public record Retry(String actionType, String actionName, long nextAttempt, Throwable cause) {
public Retry {
requireNonNull(actionType, "actionType");
requireNonNull(actionName, "actionName");
requireNonNull(cause, "cause");
}
}
}
/** Test implementation of a {@link FlowService} to be used by the flow integration tests. */
public static class TestFlowService implements FlowService {
public static final String INVALID_CONDITION = "invalid";
private final Map<FlowKey, Flow> flows = new HashMap<>();
/**
* Whether any flow creation should be rejected with a {@link FlowPermissionDeniedException}.
*/
private boolean rejectFlowCreation;
/**
* Whether any flow deletion should be rejected with a {@link FlowPermissionDeniedException}.
*/
private boolean rejectFlowDeletion;
/** Makes the flow service reject all flow creations. */
public void rejectFlowCreation() {
this.rejectFlowCreation = true;
}
/** Makes the flow service reject all flow deletions. */
public void rejectFlowDeletion() {
this.rejectFlowDeletion = true;
}
@Override
public Flow createFlow(FlowCreation flowCreation)
throws FlowPermissionDeniedException, InvalidFlowException, StorageException {
if (rejectFlowCreation) {
throw new FlowPermissionDeniedException("not allowed to create flow");
}
if (flowCreation.stageExpressions().stream()
.map(FlowExpression::condition)
.anyMatch(condition -> condition.endsWith(INVALID_CONDITION))) {
throw new InvalidFlowException(String.format("invalid condition: %s", INVALID_CONDITION));
}
FlowKey flowKey =
FlowKey.builder()
.projectName(flowCreation.projectName())
.changeId(flowCreation.changeId())
.uuid(ChangeUtil.messageUuid())
.build();
Flow flow =
Flow.builder(flowKey)
.createdOn(Instant.now())
.ownerId(flowCreation.ownerId())
.stages(
flowCreation.stageExpressions().stream()
.map(
stageExpression ->
FlowStage.builder().expression(stageExpression).build())
.collect(toImmutableList()))
.build();
flows.put(flowKey, flow);
return flow;
}
@Override
public Boolean isFlowsEnabled(Project.NameKey projectName, Change.Id changeId)
throws RestApiException {
// Always return true for testing purposes.
return true;
}
@Override
public Optional<Flow> getFlow(FlowKey flowKey) throws StorageException {
return Optional.ofNullable(flows.get(flowKey));
}
@Override
public Optional<Flow> deleteFlow(FlowKey flowKey)
throws FlowPermissionDeniedException, StorageException {
if (rejectFlowDeletion) {
throw new FlowPermissionDeniedException("not allowed to delete flow");
}
return Optional.ofNullable(flows.remove(flowKey));
}
@Override
public ImmutableList<Flow> listFlows(Project.NameKey projectName, Change.Id changeId)
throws StorageException {
return flows.entrySet().stream()
.filter(
e ->
e.getKey().projectName().equals(projectName)
&& e.getKey().changeId().equals(changeId))
.map(Map.Entry::getValue)
.collect(toImmutableList());
}
/**
* Updates the specified flow.
*
* <p>Sets the {@code lastEvaluatedOn} timestamp in the flow and updates the statuses of the
* stages.
*
* @param flowKey the key of the flow that should be updated
* @param stageStates states to be set for the stages
* @param stageMessages messages to be set for the stages
* @throws IllegalStateException thrown if the specified flow is not found, or if the number of
* given states/messages doesn't match with the number of stages in the flow
* @return the updated flow
*/
public Flow evaluate(
FlowKey flowKey,
ImmutableList<FlowStageEvaluationStatus.State> stageStates,
ImmutableList<Optional<String>> stageMessages)
throws IllegalStateException {
Optional<Flow> flow = getFlow(flowKey);
if (flow.isEmpty()) {
throw new IllegalStateException(String.format("Flow %s not found.", flowKey));
}
if (stageStates.size() != flow.get().stages().size()) {
throw new IllegalStateException(
String.format(
"Invalid number of stage states: got %s, expected %s",
stageStates.size(), flow.get().stages().size()));
}
if (stageMessages.size() != flow.get().stages().size()) {
throw new IllegalStateException(
String.format(
"Invalid number of stage messages: got %s, expected %s",
stageMessages.size(), flow.get().stages().size()));
}
List<FlowStage> stages = new ArrayList<>(flow.get().stages());
for (int i = 0; i < flow.get().stages().size(); i++) {
FlowStageEvaluationStatus.Builder updatedStatus =
stages.get(i).status().toBuilder().state(stageStates.get(i));
if (stageMessages.get(i).isPresent()) {
updatedStatus.message(stageMessages.get(i).get());
}
FlowStage updatedStage = stages.get(i).toBuilder().status(updatedStatus.build()).build();
stages.set(i, updatedStage);
}
Flow updatedFlow =
flow.get().toBuilder()
.lastEvaluatedOn(Instant.now())
.stages(ImmutableList.copyOf(stages))
.build();
flows.put(flowKey, updatedFlow);
return updatedFlow;
}
}
/**
* Test submit rule that counts how often it is invoked.
*
* <p>Using this submit rule tests can verify that submit rules are not unexpectedly invoked.
* Checking this is important since executing submit rules is rather expensive.
*/
public static class TestSubmitRule implements SubmitRule {
private int count;
public int count() {
return count;
}
@Override
public Optional<SubmitRecord> evaluate(ChangeData changeData) {
if (!isAsyncCallForSendingReviewCommentsEmail()) {
count++;
}
return Optional.empty();
}
private boolean isAsyncCallForSendingReviewCommentsEmail() {
return Arrays.stream(Thread.currentThread().getStackTrace())
.map(StackTraceElement::getClassName)
.anyMatch(className -> EmailReviewComments.class.getName().equals(className));
}
}
/**
* Private constructor to prevent instantiation of this class.
*
* <p>This class contains only static classes and hence never needs to be instantiated.
*/
private TestExtensions() {}
}