Merge "Reject push with invalid project watch config"
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 1ad00aa..93525a4 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -59,7 +59,9 @@
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
+import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.WatchConfig;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.project.RefPattern;
@@ -90,6 +92,7 @@
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -483,6 +486,40 @@
   }
 
   @Test
+  public void pushWatchConfigToUserBranch() throws Exception {
+    // change something in the user preferences to ensure that the user branch
+    // is created
+    GeneralPreferencesInfo input = new GeneralPreferencesInfo();
+    input.changesPerPage =
+        GeneralPreferencesInfo.defaults().changesPerPage + 10;
+    gApi.accounts().self().setPreferences(input);
+
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    fetch(allUsersRepo, RefNames.refsUsers(admin.id) + ":userRef");
+    allUsersRepo.reset("userRef");
+
+    Config wc = new Config();
+    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY,
+        WatchConfig.NotifyValue
+            .create(null, EnumSet.of(NotifyType.ALL_COMMENTS)).toString());
+    PushOneCommit push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
+        "Add project watch", WatchConfig.WATCH_CONFIG, wc.toText());
+    push.to(RefNames.REFS_USERS_SELF).assertOkStatus();
+
+    String invalidNotifyValue = "]invalid[";
+    wc.setString(WatchConfig.PROJECT, project.get(), WatchConfig.KEY_NOTIFY,
+        invalidNotifyValue);
+    push = pushFactory.create(db, admin.getIdent(), allUsersRepo,
+        "Add invalid project watch", WatchConfig.WATCH_CONFIG, wc.toText());
+    PushOneCommit.Result r = push.to(RefNames.REFS_USERS_SELF);
+    r.assertErrorStatus("invalid watch configuration");
+    r.assertMessage(String.format(
+        "%s: Invalid project watch of account %d for project %s: %s",
+        WatchConfig.WATCH_CONFIG, admin.getId().get(), project.get(),
+        invalidNotifyValue));
+  }
+
+  @Test
   public void addGpgKey() throws Exception {
     TestKey key = validKeyWithoutExpiration();
     String id = key.getKeyIdString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
index eae3349..8e783ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
@@ -25,6 +25,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
@@ -38,6 +39,7 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.VersionedMetaData;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -89,7 +91,8 @@
  * <p>
  * Unknown notify types are ignored and removed on save.
  */
-public class WatchConfig extends VersionedMetaData implements AutoCloseable {
+public class WatchConfig extends VersionedMetaData
+    implements AutoCloseable, ValidationError.Sink {
   @Singleton
   public static class Accessor {
     private final GitRepositoryManager repoManager;
@@ -176,15 +179,16 @@
     public abstract @Nullable String filter();
   }
 
-  private static final String WATCH_CONFIG = "watch.config";
-  private static final String PROJECT = "project";
-  private static final String KEY_NOTIFY = "notify";
+  public static final String WATCH_CONFIG = "watch.config";
+  public static final String PROJECT = "project";
+  public static final String KEY_NOTIFY = "notify";
 
   private final Account.Id accountId;
   private final String ref;
 
   private Repository git;
   private Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
+  private List<ValidationError> validationErrors;
 
   public WatchConfig(Account.Id accountId) {
     this.accountId = accountId;
@@ -206,12 +210,13 @@
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     Config cfg = readConfig(WATCH_CONFIG);
-    projectWatches = parse(accountId, cfg);
+    projectWatches = parse(accountId, cfg, this);
   }
 
   @VisibleForTesting
   public static Map<ProjectWatchKey, Set<NotifyType>> parse(
-      Account.Id accountId, Config cfg) throws ConfigInvalidException {
+      Account.Id accountId, Config cfg,
+      ValidationError.Sink validationErrorSink) {
     Map<ProjectWatchKey, Set<NotifyType>> projectWatches = new HashMap<>();
     for (String projectName : cfg.getSubsections(PROJECT)) {
       String[] notifyValues =
@@ -221,7 +226,12 @@
           continue;
         }
 
-        NotifyValue notifyValue = NotifyValue.parse(accountId, projectName, nv);
+        NotifyValue notifyValue =
+            NotifyValue.parse(accountId, projectName, nv, validationErrorSink);
+        if (notifyValue == null) {
+          continue;
+        }
+
         ProjectWatchKey key = ProjectWatchKey
             .create(new Project.NameKey(projectName), notifyValue.filter());
         if (!projectWatches.containsKey(key)) {
@@ -283,16 +293,38 @@
     checkState(projectWatches != null, "project watches not loaded yet");
   }
 
+  @Override
+  public void error(ValidationError error) {
+    if (validationErrors == null) {
+      validationErrors = new ArrayList<>(4);
+    }
+    validationErrors.add(error);
+  }
+
+  /**
+   * Get the validation errors, if any were discovered during load.
+   *
+   * @return list of errors; empty list if there are no errors.
+   */
+  public List<ValidationError> getValidationErrors() {
+    if (validationErrors != null) {
+      return ImmutableList.copyOf(validationErrors);
+    }
+    return ImmutableList.of();
+  }
+
   @AutoValue
-  abstract static class NotifyValue {
+  public abstract static class NotifyValue {
     public static NotifyValue parse(Account.Id accountId, String project,
-        String notifyValue) throws ConfigInvalidException {
+        String notifyValue, ValidationError.Sink validationErrorSink) {
       notifyValue = notifyValue.trim();
       int i = notifyValue.lastIndexOf('[');
       if (i < 0 || notifyValue.charAt(notifyValue.length() - 1) != ']') {
-        throw new ConfigInvalidException(String.format(
-            "Invalid project watch of account %d for project %s: %s",
-            accountId.get(), project, notifyValue));
+        validationErrorSink.error(new ValidationError(WATCH_CONFIG,
+            String.format(
+                "Invalid project watch of account %d for project %s: %s",
+                accountId.get(), project, notifyValue)));
+        return null;
       }
       String filter = notifyValue.substring(0, i).trim();
       if (filter.isEmpty() || AccountProjectWatch.FILTER_ALL.equals(filter)) {
@@ -306,10 +338,12 @@
           Optional<NotifyType> notifyType =
               Enums.getIfPresent(NotifyType.class, nt);
           if (!notifyType.isPresent()) {
-            throw new ConfigInvalidException(String.format(
-                "Invalid notify type %s in project watch "
-                    + "of account %d for project %s: %s",
-                nt, accountId.get(), project, notifyValue));
+            validationErrorSink.error(new ValidationError(WATCH_CONFIG,
+                String.format(
+                    "Invalid notify type %s in project watch "
+                        + "of account %d for project %s: %s",
+                    nt, accountId.get(), project, notifyValue)));
+            continue;
           }
           notifyTypes.add(notifyType.get());
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index ad2003e..e7b19aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -22,8 +22,12 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -86,22 +90,26 @@
   private final SshInfo sshInfo;
   private final Repository repo;
   private final DynamicSet<CommitValidationListener> commitValidationListeners;
+  private final AllUsersName allUsers;
 
   @Inject
-  CommitValidators(@GerritPersonIdent final PersonIdent gerritIdent,
-      @CanonicalWebUrl @Nullable final String canonicalWebUrl,
-      @GerritServerConfig final Config config,
-      final DynamicSet<CommitValidationListener> commitValidationListeners,
-      @Assisted final SshInfo sshInfo,
-      @Assisted final Repository repo, @Assisted final RefControl refControl) {
+  CommitValidators(@GerritPersonIdent PersonIdent gerritIdent,
+      @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      @GerritServerConfig Config config,
+      DynamicSet<CommitValidationListener> commitValidationListeners,
+      AllUsersName allUsers,
+      @Assisted SshInfo sshInfo,
+      @Assisted Repository repo,
+      @Assisted RefControl refControl) {
     this.gerritIdent = gerritIdent;
-    this.refControl = refControl;
     this.canonicalWebUrl = canonicalWebUrl;
     this.installCommitMsgHookCommand =
         config.getString("gerrit", null, "installCommitMsgHookCommand");
+    this.commitValidationListeners = commitValidationListeners;
+    this.allUsers = allUsers;
     this.sshInfo = sshInfo;
     this.repo = repo;
-    this.commitValidationListeners = commitValidationListeners;
+    this.refControl = refControl;
   }
 
   public List<CommitValidationMessage> validateForReceiveCommits(
@@ -122,7 +130,7 @@
       validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
           installCommitMsgHookCommand, sshInfo));
     }
-    validators.add(new ConfigValidator(refControl, repo));
+    validators.add(new ConfigValidator(refControl, repo, allUsers));
     validators.add(new BannedCommitsValidator(rejectCommits));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
 
@@ -156,7 +164,7 @@
       validators.add(new ChangeIdValidator(refControl, canonicalWebUrl,
           installCommitMsgHookCommand, sshInfo));
     }
-    validators.add(new ConfigValidator(refControl, repo));
+    validators.add(new ConfigValidator(refControl, repo, allUsers));
     validators.add(new PluginCommitValidationListener(commitValidationListeners));
 
     List<CommitValidationMessage> messages = new LinkedList<>();
@@ -321,10 +329,13 @@
   public static class ConfigValidator implements CommitValidationListener {
     private final RefControl refControl;
     private final Repository repo;
+    private final AllUsersName allUsers;
 
-    public ConfigValidator(RefControl refControl, Repository repo) {
+    public ConfigValidator(RefControl refControl, Repository repo,
+        AllUsersName allUsers) {
       this.refControl = refControl;
       this.repo = repo;
+      this.allUsers = allUsers;
     }
 
     @Override
@@ -355,6 +366,35 @@
               messages);
         }
       }
+
+      if (allUsers.equals(
+              refControl.getProjectControl().getProject().getNameKey())
+          && RefNames.isRefsUsers(refControl.getRefName())) {
+        List<CommitValidationMessage> messages = new LinkedList<>();
+        Account.Id accountId = Account.Id.fromRef(refControl.getRefName());
+        if (accountId != null) {
+          try {
+            @SuppressWarnings("resource")
+            WatchConfig wc = new WatchConfig(accountId);
+            wc.load(repo, receiveEvent.command.getNewId());
+            if (!wc.getValidationErrors().isEmpty()) {
+              addError("Invalid project configuration:", messages);
+              for (ValidationError err : wc.getValidationErrors()) {
+                addError("  " + err.getMessage(), messages);
+              }
+              throw new ConfigInvalidException("invalid watch configuration");
+            }
+          } catch (IOException | ConfigInvalidException e) {
+            log.error("User " + currentUser.getUserName()
+                + " tried to push an invalid watch configuration "
+                + receiveEvent.command.getNewId().name() + " for account "
+                + accountId.get(), e);
+            throw new CommitValidationException("invalid watch configuration",
+                messages);
+          }
+        }
+      }
+
       return Collections.emptyList();
     }
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
index 0133ec2..0619a78 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/account/WatchConfigTest.java
@@ -15,28 +15,32 @@
 package com.google.gerrit.server.account;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.WatchConfig.NotifyValue;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.git.ValidationError;
 
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.junit.Rule;
+import org.junit.Before;
 import org.junit.Test;
-import org.junit.rules.ExpectedException;
 
+import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
-public class WatchConfigTest {
-  @Rule
-  public ExpectedException exception = ExpectedException.none();
+public class WatchConfigTest implements ValidationError.Sink {
+  private List<ValidationError> validationErrors = new ArrayList<>();
+
+  @Before
+  public void setup() {
+    validationErrors.clear();
+  }
 
   @Test
   public void parseWatchConfig() throws Exception {
@@ -50,7 +54,9 @@
         + "  notify = [NEW_PATCHSETS]\n"
         + "  notify = * [NEW_PATCHSETS, ALL_COMMENTS]\n");
     Map<ProjectWatchKey, Set<NotifyType>> projectWatches =
-        WatchConfig.parse(new Account.Id(1000000), cfg);
+        WatchConfig.parse(new Account.Id(1000000), cfg, this);
+
+    assertThat(validationErrors).isEmpty();
 
     Project.NameKey myProject = new Project.NameKey("myProject");
     Project.NameKey otherProject = new Project.NameKey("otherProject");
@@ -79,11 +85,12 @@
         + "[project \"otherProject\"]\n"
         + "  notify = [NEW_PATCHSETS]\n");
 
-    exception.expect(ConfigInvalidException.class);
-    exception.expectMessage(
-        "Invalid notify type INVALID in project watch of account 1000000"
-            + " for project myProject: branch:master [INVALID, NEW_CHANGES]");
-    WatchConfig.parse(new Account.Id(1000000), cfg);
+    WatchConfig.parse(new Account.Id(1000000), cfg, this);
+    assertThat(validationErrors).hasSize(1);
+    assertThat(validationErrors.get(0).getMessage()).isEqualTo(
+        "watch.config: Invalid notify type INVALID in project watch of"
+            + " account 1000000 for project myProject: branch:master"
+            + " [INVALID, NEW_CHANGES]");
   }
 
   @Test
@@ -104,6 +111,8 @@
         "branch:master",
         EnumSet.of(NotifyType.ALL_COMMENTS, NotifyType.NEW_PATCHSETS));
     assertParseNotifyValue("* [ALL]", null, EnumSet.of(NotifyType.ALL));
+
+    assertThat(validationErrors).isEmpty();
   }
 
   @Test
@@ -136,9 +145,8 @@
     assertToNotifyValue("*", EnumSet.of(NotifyType.ALL), "* [ALL]");
   }
 
-  private static void assertParseNotifyValue(String notifyValue,
-      String expectedFilter, Set<NotifyType> expectedNotifyTypes)
-          throws ConfigInvalidException {
+  private void assertParseNotifyValue(String notifyValue,
+      String expectedFilter, Set<NotifyType> expectedNotifyTypes) {
     NotifyValue nv = parseNotifyValue(notifyValue);
     assertThat(nv.filter()).isEqualTo(expectedFilter);
     assertThat(nv.notifyTypes()).containsExactlyElementsIn(expectedNotifyTypes);
@@ -150,17 +158,21 @@
     assertThat(nv.toString()).isEqualTo(expectedNotifyValue);
   }
 
-  private static void assertParseNotifyValueFails(String notifyValue) {
-    try {
-      parseNotifyValue(notifyValue);
-      fail("expected ConfigInvalidException for notifyValue: " + notifyValue);
-    } catch (ConfigInvalidException e) {
-      // Expected.
-    }
+  private void assertParseNotifyValueFails(String notifyValue) {
+    assertThat(validationErrors).isEmpty();
+    parseNotifyValue(notifyValue);
+    assertThat(validationErrors)
+        .named("expected validation error for notifyValue: " + notifyValue)
+        .isNotEmpty();
+    validationErrors.clear();
   }
 
-  private static NotifyValue parseNotifyValue(String notifyValue)
-      throws ConfigInvalidException {
-    return NotifyValue.parse(new Account.Id(1000000), "project", notifyValue);
+  private NotifyValue parseNotifyValue(String notifyValue) {
+    return NotifyValue.parse(new Account.Id(1000000), "project", notifyValue, this);
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    validationErrors.add(error);
   }
 }