Validate reviewer filters

REST:
* Validate reviewer filters before adding them to reviewers.config.
git:
* Validate reviewer filters before update of reviewers.config.

Introduce a query validation API.

Feature: Issue 12650
Change-Id: I1404598869ccf15714063a088e1410cc8ba5274a
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/ForProjectValidator.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ForProjectValidator.java
new file mode 100644
index 0000000..3492e03
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ForProjectValidator.java
@@ -0,0 +1,112 @@
+// Copyright (C) 2020 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.reviewers;
+
+import autovaluegson.factory.shaded.com.google.common.collect.Lists;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet.Id;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.MergeValidationException;
+import com.google.gerrit.server.git.validators.MergeValidationListener;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.reviewers.config.ForProject;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+
+/** Validates changes to reviewers.config through push or merge. */
+@Singleton
+public class ForProjectValidator implements MergeValidationListener, CommitValidationListener {
+  @VisibleForTesting public static String MALFORMED_CONFIG = "Malformed reviewers.config";
+
+  private final ForProject.Factory forProjectFactory;
+
+  @Inject
+  public ForProjectValidator(ForProject.Factory forProjectFactory) {
+    this.forProjectFactory = forProjectFactory;
+  }
+
+  @Override
+  public void onPreMerge(
+      Repository repo,
+      CodeReviewCommit.CodeReviewRevWalk crrw,
+      CodeReviewCommit commit,
+      ProjectState destProject,
+      BranchNameKey destBranch,
+      Id patchSetId,
+      IdentifiedUser caller)
+      throws MergeValidationException {
+    if (!RefNames.REFS_CONFIG.equals(destBranch.branch())) {
+      return;
+    }
+
+    ForProject cfg = forProjectFactory.create();
+    try {
+      cfg.load(destProject.getNameKey(), repo, commit);
+    } catch (IOException ioe) {
+      throw new MergeValidationException("Unable to read config.", ioe);
+    } catch (ConfigInvalidException cie) {
+      throw new MergeValidationException(MALFORMED_CONFIG, cie);
+    }
+
+    if (!cfg.getValidationErrors().isEmpty()) {
+      throw new MergeValidationException(formatValidationErrors(cfg.getValidationErrors()));
+    }
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    if (RefNames.REFS_CONFIG.equals(receiveEvent.getBranchNameKey().branch())) {
+      ForProject cfg = forProjectFactory.create();
+      try {
+        cfg.load(receiveEvent.getProjectNameKey(), receiveEvent.revWalk, receiveEvent.commit);
+      } catch (IOException ioe) {
+        throw new CommitValidationException("Unable to read config.", ioe);
+      } catch (ConfigInvalidException cie) {
+        throw new CommitValidationException(MALFORMED_CONFIG);
+      }
+      if (!cfg.getValidationErrors().isEmpty()) {
+        ArrayList<CommitValidationMessage> messages = Lists.newArrayList();
+        messages.add(new CommitValidationMessage(MALFORMED_CONFIG, true));
+        cfg.getValidationErrors()
+            .forEach(ve -> messages.add(new CommitValidationMessage("  " + ve.getMessage(), true)));
+        throw new CommitValidationException(MALFORMED_CONFIG, messages);
+      }
+    }
+    return Collections.emptyList();
+  }
+
+  private static String formatValidationErrors(List<ValidationError> errors) {
+    StringBuilder errorMessage = new StringBuilder();
+    errorMessage.append("[\"");
+    errors.forEach(ve -> errorMessage.append(ve.getMessage() + "\", \""));
+    errorMessage.append("]");
+    return "Malformed reviewers.config filters: " + errorMessage.toString();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java
index c069a0c..20b09be 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/Module.java
@@ -28,8 +28,11 @@
 import com.google.gerrit.extensions.webui.JavaScriptPlugin;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.reviewers.config.ConfigModule;
 import com.googlesource.gerrit.plugins.reviewers.config.GlobalConfig;
 
 public class Module extends FactoryModule {
@@ -52,7 +55,8 @@
     bind(CapabilityDefinition.class)
         .annotatedWith(Exports.named(MODIFY_REVIEWERS_CONFIG))
         .to(ModifyReviewersConfigCapability.class);
-
+    DynamicSet.bind(binder(), MergeValidationListener.class).to(ForProjectValidator.class);
+    DynamicSet.bind(binder(), CommitValidationListener.class).to(ForProjectValidator.class);
     if (suggestOnly) {
       install(
           new AbstractModule() {
@@ -89,6 +93,7 @@
                 .toInstance(new JavaScriptPlugin("rv-reviewers.js"));
           }
         });
+    install(new ConfigModule());
   }
 
   protected void bindWorkQueue() {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java
index e9945e4..58ae003 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/PutReviewers.java
@@ -16,12 +16,15 @@
 
 import static com.googlesource.gerrit.plugins.reviewers.ModifyReviewersConfigCapability.MODIFY_REVIEWERS_CONFIG;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.PluginName;
 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;
@@ -30,6 +33,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -45,6 +49,7 @@
 import com.googlesource.gerrit.plugins.reviewers.config.ForProject;
 import java.io.IOException;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
@@ -67,6 +72,7 @@
 
   private final String pluginName;
   private final FiltersFactory filters;
+  private final ForProject.Factory forProjectFactory;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final ProjectCache projectCache;
   private final AccountResolver accountResolver;
@@ -77,6 +83,7 @@
   PutReviewers(
       @PluginName String pluginName,
       FiltersFactory filters,
+      ForProject.Factory forProjectFactory,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
       AccountResolver accountResolver,
@@ -84,6 +91,7 @@
       PermissionBackend permissionBackend) {
     this.pluginName = pluginName;
     this.filters = filters;
+    this.forProjectFactory = forProjectFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.projectCache = projectCache;
     this.accountResolver = accountResolver;
@@ -95,8 +103,7 @@
   public Response<List<ReviewerFilter>> apply(ProjectResource rsrc, Input input)
       throws RestApiException, PermissionBackendException {
     Project.NameKey projectName = rsrc.getNameKey();
-    ForProject forProject = new ForProject();
-
+    ForProject forProject = forProjectFactory.create();
     PermissionBackend.WithUser userPermission = permissionBackend.user(rsrc.getUser());
     if (!userPermission.project(rsrc.getNameKey()).testOrFalse(ProjectPermission.WRITE_CONFIG)
         && !userPermission.testOrFalse(new PluginPermission(pluginName, MODIFY_REVIEWERS_CONFIG))) {
@@ -113,6 +120,7 @@
       try {
         StringBuilder message = new StringBuilder(pluginName).append(" plugin: ");
         forProject.load(md);
+        Set<ValidationError> previousErrors = ImmutableSet.copyOf(forProject.getValidationErrors());
         if (input.action == Action.ADD) {
           message
               .append("Add ")
@@ -131,6 +139,12 @@
               .append(input.filter);
           forProject.removeReviewer(input.filter, input.reviewer, input.type);
         }
+        Set<ValidationError> errors =
+            Sets.difference(ImmutableSet.copyOf(forProject.getValidationErrors()), previousErrors);
+        if (!errors.isEmpty()) {
+          throw new BadRequestException(
+              String.format("Unsupported query: %s", errors.iterator().next().getMessage()));
+        }
         message.append("\n");
         md.setMessage(message.toString());
         try {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java
index b991760..810c2bd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/ReviewerFilter.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.reviewers;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.util.Objects;
 import java.util.Set;
 
@@ -30,6 +31,7 @@
   protected String filter;
   protected Set<String> reviewers;
   protected Set<String> ccs;
+  protected String filterError;
 
   String getFilter() {
     return filter;
@@ -43,6 +45,11 @@
     return ccs;
   }
 
+  @VisibleForTesting
+  public String getFilterError() {
+    return filterError;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof ReviewerFilter) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ConfigModule.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ConfigModule.java
new file mode 100644
index 0000000..fbf7d64
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ConfigModule.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2022 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.reviewers.config;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+
+public class ConfigModule extends FactoryModule {
+
+  @Override
+  protected void configure() {
+    factory(ForProject.Factory.class);
+    factory(ReviewerFilterCollection.Factory.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java
index 04c3d62..9bdf676 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/FiltersFactory.java
@@ -30,11 +30,16 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PluginConfigFactory configFactory;
+  private final ReviewerFilterCollection.Factory filterCollectionFactory;
   private final String pluginName;
 
   @Inject
-  public FiltersFactory(PluginConfigFactory configFactory, @PluginName String pluginName) {
+  public FiltersFactory(
+      PluginConfigFactory configFactory,
+      ReviewerFilterCollection.Factory filterCollectionFactory,
+      @PluginName String pluginName) {
     this.configFactory = configFactory;
+    this.filterCollectionFactory = filterCollectionFactory;
     this.pluginName = pluginName;
   }
 
@@ -46,6 +51,6 @@
       logger.atSevere().log("Unable to get config for project %s", projectName.get());
       cfg = new Config();
     }
-    return new ReviewerFilterCollection(cfg).getAll();
+    return filterCollectionFactory.create(cfg).getAll();
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java
index c53fdba..125fc9d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ForProject.java
@@ -17,21 +17,37 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.reviewers.ReviewerType;
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 
-public class ForProject extends VersionedMetaData {
+public class ForProject extends VersionedMetaData implements ValidationError.Sink {
   @VisibleForTesting public static final String FILENAME = "reviewers.config";
   @VisibleForTesting public static final String SECTION_FILTER = "filter";
   @VisibleForTesting public static final String KEY_CC = "cc";
   @VisibleForTesting public static final String KEY_REVIEWER = "reviewer";
 
+  public interface Factory {
+    public ForProject create();
+  }
+
+  private final ReviewerFilterCollection.Factory filterCollectionFactory;
   private Config cfg;
   private ReviewerFilterCollection filters;
+  private List<ValidationError> validationErrors;
+
+  @Inject
+  public ForProject(ReviewerFilterCollection.Factory filterCollectionFactory) {
+    this.filterCollectionFactory = filterCollectionFactory;
+  }
 
   public void addReviewer(String filter, String reviewer, ReviewerType type) {
     switch (type) {
@@ -53,6 +69,26 @@
     }
   }
 
+  /**
+   * Get the validation errors, if any were discovered.
+   *
+   * @return list of errors; empty list if there are no errors.
+   */
+  public List<ValidationError> getValidationErrors() {
+    if (validationErrors != null) {
+      return Collections.unmodifiableList(validationErrors);
+    }
+    return Collections.emptyList();
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    if (validationErrors == null) {
+      validationErrors = new ArrayList<>(4);
+    }
+    validationErrors.add(error);
+  }
+
   @Override
   protected String getRefName() {
     return RefNames.REFS_CONFIG;
@@ -61,7 +97,7 @@
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     this.cfg = readConfig(FILENAME);
-    this.filters = new ReviewerFilterCollection(cfg);
+    this.filters = filterCollectionFactory.create(cfg, this);
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java
index bd44ccd..91b49e8 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollection.java
@@ -21,29 +21,80 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import com.googlesource.gerrit.plugins.reviewers.ReviewerFilter;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 
 /** Representation of the collection of {@link ReviewerFilter}s in a {@link Config}. */
 class ReviewerFilterCollection {
 
+  private final ReviewersQueryValidator queryValidator;
   private final Config cfg;
+  private final Optional<ValidationError.Sink> validationErrorSink;
 
-  ReviewerFilterCollection(Config cfg) {
+  interface Factory {
+    ReviewerFilterCollection create(Config cfg);
+
+    ReviewerFilterCollection create(Config cfg, ValidationError.Sink validationErrorSink);
+  }
+
+  @AssistedInject
+  ReviewerFilterCollection(ReviewersQueryValidator queryValidator, @Assisted Config cfg) {
+    this(queryValidator, cfg, null);
+  }
+
+  @AssistedInject
+  ReviewerFilterCollection(
+      ReviewersQueryValidator queryValidator,
+      @Assisted Config cfg,
+      @Assisted ValidationError.Sink validationErrorSink) {
+    this.queryValidator = queryValidator;
     this.cfg = cfg;
+    this.validationErrorSink = Optional.ofNullable(validationErrorSink);
+    check();
   }
 
   List<ReviewerFilter> getAll() {
     ImmutableList.Builder<ReviewerFilter> b = ImmutableList.builder();
     for (String f : cfg.getSubsections(SECTION_FILTER)) {
-      b.add(new ReviewerFilterSection(f));
+      b.add(newReviewerFilter(f));
     }
     return b.build();
   }
 
+  /* Validates all the filter in this collection and adds the ValidationErrors
+   * to the ValidationError.Sink. */
+  private void check() {
+    for (String f : cfg.getSubsections(SECTION_FILTER)) {
+      checkForErrors(f);
+    }
+  }
+
   ReviewerFilterSection get(String filter) {
-    return new ReviewerFilterSection(filter);
+    return newReviewerFilter(filter);
+  }
+
+  private ReviewerFilterSection newReviewerFilter(String filter) {
+    ReviewerFilterSection section = new ReviewerFilterSection(filter);
+    checkForErrors(filter).ifPresent(err -> section.filterError(err));
+    return section;
+  }
+
+  /* Checks if filterQuery is a valid query. If not it adds the corresponding
+   * ValidationError to the ValidationError.Sink and returns the error. */
+  private Optional<String> checkForErrors(String filterQuery) {
+    try {
+      queryValidator.validateQuery(filterQuery);
+    } catch (QueryParseException qpe) {
+      validationErrorSink.ifPresent(ves -> ves.error(ValidationError.create(qpe.getMessage())));
+      return Optional.of(qpe.getMessage());
+    }
+    return Optional.empty();
   }
 
   class ReviewerFilterSection extends ReviewerFilter {
@@ -74,6 +125,10 @@
       save();
     }
 
+    public void filterError(String error) {
+      this.filterError = error;
+    }
+
     private void save() {
       if (this.reviewers.isEmpty() && this.ccs.isEmpty()) {
         cfg.unsetSection(SECTION_FILTER, filter);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersQueryValidator.java b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersQueryValidator.java
new file mode 100644
index 0000000..4793a57
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersQueryValidator.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2020 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.reviewers.config;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+/** Validates that a reviewer filter query is formatted correctly. */
+@Singleton
+public class ReviewersQueryValidator {
+  private final Provider<ChangeQueryBuilder> queryBuilder;
+
+  @Inject
+  public ReviewersQueryValidator(Provider<ChangeQueryBuilder> queryBuilder) {
+    this.queryBuilder = queryBuilder;
+  }
+
+  void validateQuery(String query) throws QueryParseException {
+    queryBuilder.get().parse(query);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java b/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java
index cd13812..a9ab6e4 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/reviewers/AbstractReviewersPluginTest.java
@@ -23,11 +23,13 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.RefNames;
 import com.google.inject.Inject;
 import java.util.Arrays;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -39,10 +41,21 @@
   @Inject protected ProjectOperations projectOperations;
 
   protected void createFilters(TestFilter... filters) throws Exception {
-    createFiltersFor(testRepo, filters);
+    createFiltersFor(testRepo, Optional.empty(), filters);
   }
 
   protected void createFiltersFor(TestRepository<?> repo, TestFilter... filters) throws Exception {
+    createFiltersFor(repo, Optional.empty(), filters);
+  }
+
+  protected void createFiltersWithError(String errorMessage, TestFilter... filters)
+      throws Exception {
+    createFiltersFor(testRepo, Optional.of(errorMessage), filters);
+  }
+
+  protected void createFiltersFor(
+      TestRepository<?> repo, Optional<String> errorMessage, TestFilter... filters)
+      throws Exception {
     String previousHead = repo.getRepository().getBranch();
     checkoutRefsMetaConfig(repo);
     Config cfg = new Config();
@@ -53,10 +66,15 @@
                   SECTION_FILTER, f.filter, KEY_REVIEWER, Lists.newArrayList(f.reviewers));
               cfg.setStringList(SECTION_FILTER, f.filter, KEY_CC, Lists.newArrayList(f.ccs));
             });
-    pushFactory
-        .create(admin.newIdent(), repo, "Add reviewers", FILENAME, cfg.toText())
-        .to(RefNames.REFS_CONFIG)
-        .assertOkStatus();
+    PushOneCommit.Result result =
+        pushFactory
+            .create(admin.newIdent(), repo, "Add reviewers", FILENAME, cfg.toText())
+            .to(RefNames.REFS_CONFIG);
+    if (errorMessage.isPresent()) {
+      result.assertErrorStatus(errorMessage.get());
+    } else {
+      result.assertOkStatus();
+    }
     repo.reset(previousHead);
   }
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollectionIT.java b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollectionIT.java
new file mode 100644
index 0000000..7d4fd4f
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewerFilterCollectionIT.java
@@ -0,0 +1,55 @@
+// 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.reviewers.config;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.reviewers.config.ForProject.KEY_REVIEWER;
+import static com.googlesource.gerrit.plugins.reviewers.config.ForProject.SECTION_FILTER;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.extensions.config.FactoryModule;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+@NoHttpd
+@TestPlugin(
+    name = "reviewers",
+    sysModule =
+        "com.googlesource.gerrit.plugins.reviewers.config.ReviewerFilterCollectionIT$TestModule")
+public class ReviewerFilterCollectionIT extends LightweightPluginDaemonTest {
+
+  @Test
+  public void malformedFilterErrorPropagated() throws Exception {
+    String malformedQuery = "malformed:query";
+    Config malformed = new Config();
+    malformed.setString(SECTION_FILTER, malformedQuery, KEY_REVIEWER, "User");
+    assertThat(filters().create(malformed).get(malformedQuery).getFilterError())
+        .isEqualTo(String.format("Unsupported operator %s", malformedQuery));
+  }
+
+  private ReviewerFilterCollection.Factory filters() {
+    return plugin.getSysInjector().getInstance(ReviewerFilterCollection.Factory.class);
+  }
+
+  public static class TestModule extends FactoryModule {
+
+    @Override
+    public void configure() {
+      factory(ReviewerFilterCollection.Factory.class);
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java
index 5f71d91..be761fb 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/reviewers/config/ReviewersConfigIT.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.acceptance.TestPlugin;
 import com.google.gerrit.entities.Project;
 import com.googlesource.gerrit.plugins.reviewers.AbstractReviewersPluginTest;
+import com.googlesource.gerrit.plugins.reviewers.ForProjectValidator;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Before;
 import org.junit.Test;
@@ -29,6 +30,7 @@
 @TestPlugin(name = "reviewers", sysModule = "com.googlesource.gerrit.plugins.reviewers.Module")
 public class ReviewersConfigIT extends AbstractReviewersPluginTest {
   private static final String BRANCH_MAIN = "branch:main";
+  private static final String MALFORMED_FILTER = "branches:master,stable2";
   private static final String NO_FILTER = "*";
   private static final String JANE_DOE = "jane.doe@example.com";
   private static final String JOHN_DOE = "john.doe@example.com";
@@ -77,6 +79,12 @@
         filter(BRANCH_MAIN).reviewer(JOHN_DOE).cc(JANE_DOE));
   }
 
+  @Test
+  public void malformedFilterQuery() throws Exception {
+    createFiltersWithError(
+        ForProjectValidator.MALFORMED_CONFIG, filter(MALFORMED_FILTER).reviewer(JOHN_DOE));
+  }
+
   private void assertProjectHasFilters(Project.NameKey project, TestFilter... filters) {
     assertThat(filters().withInheritance(project))
         .containsExactlyElementsIn(ImmutableList.copyOf(filters));