Add REST endpoint to check consistency of external IDs

The REST endpoint is generic so that further consistency checks can be
added later. Each consistency check has a specific input entity so that
sepcific options for a check can be set. At the moment the consistency
check for external IDs doesn't support any input options, but we may add
options later, e.g. to tell the consistency check to automatically fix
certain inconsistencies.

Change-Id: I2ae76ea9254798744d8d5408d1ba640931319ed8
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 62e3ee4..fe2025c 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -138,6 +138,51 @@
   }
 ----
 
+[[check-consistency]]
+=== Check Consistency
+--
+'POST /config/server/check'
+--
+
+Runs consistency checks and returns detected problems.
+
+Input for the consistency checks that should be run must be provided in
+the request body inside a
+link:#consistency-check-input[ConsistencyCheckInput] entity.
+
+.Request
+----
+  POST /config/server/check HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "check_account_external_ids": {}
+  }
+----
+
+As result a link:#consistency-check-info[ConsistencyCheckInfo] entity
+is returned that contains detected consistency problems.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "results": {
+      "account_external_id_result": {
+        "problems": [
+          {
+            "status": "ERROR",
+            "message": "External ID \u0027uuid:ccb8d323-1361-45aa-8874-41987a660c46\u0027 belongs to account that doesn\u0027t exist: 1000012"
+          }
+        ]
+      }
+    }
+  }
+----
+
 [[confirm-email]]
 === Confirm Email
 --
@@ -1365,6 +1410,66 @@
 the whole topic is submitted].
 |=============================
 
+[[check-account-external-ids-input]]
+=== CheckAccountExternalIdsInput
+The `CheckAccountExternalIdsInput` entity contains input for the
+account external IDs consistency check.
+
+Currently this entity contains no fields.
+
+[[check-account-external-ids-result-info]]
+=== CheckAccountExternalIdsResultInfo
+The `CheckAccountExternalIdsResultInfo` entity contains the result of
+running the account external IDs consistency check.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`problems`|A list of link:#consistency-problem-info[
+ConsistencyProblemInfo] entities.
+|======================
+
+[[consistency-check-info]]
+=== ConsistencyCheckInfo
+The `ConsistencyCheckInfo` entity contains the results of running
+consistency checks.
+
+[options="header",cols="1,^1,5"]
+|================================================
+|Field Name                         ||Description
+|`check_account_external_ids_result`|optional|
+The result of running the account external ID consistency check as a
+link:#check-account-external-ids-result-info[
+CheckAccountExternalIdsResultInfo] entity.
+|================================================
+
+[[consistency-check-input]]
+=== ConsistencyCheckInput
+The `ConsistencyCheckInput` entity contains information about which
+consistency checks should be run.
+
+[options="header",cols="1,^1,5"]
+|=========================================
+|Field Name                  ||Description
+|`check_account_external_ids`|optional|
+Input for the account external ID consistency check as
+link:#check-account-external-ids-input[CheckAccountExternalIdsInput]
+entity.
+|=========================================
+
+[[consistency-problem-info]]
+=== ConsistencyProblemInfo
+The `ConsistencyProblemInfo` entity contains information about a
+consistency problem.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name|Description
+|`status`  |The status of the consistency problem. +
+Possible values are `ERROR` and `WARNING`.
+|`message` |Message describing the consistency problem.
+|======================
+
 [[download-info]]
 === DownloadInfo
 The `DownloadInfo` entity contains information about supported download
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index d5d4620..817ecdd 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -19,6 +19,8 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
 
 import com.github.rholder.retry.BlockStrategy;
@@ -33,7 +35,12 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput.CheckAccountExternalIdsInput;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -54,6 +61,7 @@
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -61,11 +69,13 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.MutableInteger;
 import org.junit.Test;
 
 @Sandboxed
@@ -199,6 +209,229 @@
   }
 
   @Test
+  public void checkConsistency() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    resetCurrentApiUser();
+
+    MutableInteger i = new MutableInteger();
+    ExternalIdsUpdate u = extIdsUpdate.create();
+
+    // create valid external IDs
+    u.insert(
+        db,
+        ExternalId.createWithPassword(
+            ExternalId.Key.parse(nextId(i)),
+            admin.id,
+            "admin.other@example.com",
+            "secret-password"));
+    u.insert(db, createExternalIdWithOtherCaseEmail(nextId(i)));
+
+    ConsistencyCheckInput input = new ConsistencyCheckInput();
+    input.checkAccountExternalIds = new CheckAccountExternalIdsInput();
+    ConsistencyCheckInfo checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).isEmpty();
+
+    // create invalid external IDs
+    Set<ConsistencyProblemInfo> expectedProblems = new HashSet<>();
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      String externalId = nextId(i);
+      String noteId = insertExternalIdWithoutAccountId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Value for 'externalId."
+                  + externalId
+                  + ".accountId' is missing, expected account ID"));
+
+      externalId = nextId(i);
+      noteId = insertExternalIdWithKeyThatDoesntMatchNoteId(repo, rw, externalId);
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': SHA1 of external ID '"
+                  + externalId
+                  + "' does not match note ID '"
+                  + noteId
+                  + "'"));
+
+      noteId = insertExternalIdWithInvalidConfig(repo, rw, nextId(i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '" + noteId + "': Invalid line in config file"));
+
+      noteId = insertExternalIdWithEmptyNote(repo, rw, nextId(i));
+      expectedProblems.add(
+          consistencyError(
+              "Invalid external ID config for note '"
+                  + noteId
+                  + "': Expected exactly 1 'externalId' section, found 0"));
+    }
+
+    ExternalId extIdForNonExistingAccount = createExternalIdForNonExistingAccount(nextId(i));
+    u.insert(db, extIdForNonExistingAccount);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdForNonExistingAccount.key().get()
+                + "' belongs to account that doesn't exist: "
+                + extIdForNonExistingAccount.accountId().get()));
+
+    ExternalId extIdWithInvalidEmail = createExternalIdWithInvalidEmail(nextId(i));
+    u.insert(db, extIdWithInvalidEmail);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithInvalidEmail.key().get()
+                + "' has an invalid email: "
+                + extIdWithInvalidEmail.email()));
+
+    ExternalId extIdWithDuplicateEmail = createExternalIdWithDuplicateEmail(nextId(i));
+    u.insert(db, extIdWithDuplicateEmail);
+    expectedProblems.add(
+        consistencyError(
+            "Email '"
+                + extIdWithDuplicateEmail.email()
+                + "' is not unique, it's used by the following external IDs: '"
+                + extIdWithDuplicateEmail.key().get()
+                + "', 'mailto:"
+                + extIdWithDuplicateEmail.email()
+                + "'"));
+
+    ExternalId extIdWithBadPassword = createExternalIdWithBadPassword("admin-username");
+    u.insert(db, extIdWithBadPassword);
+    expectedProblems.add(
+        consistencyError(
+            "External ID '"
+                + extIdWithBadPassword.key().get()
+                + "' has an invalid password: unrecognized algorithm"));
+
+    checkInfo = gApi.config().server().checkConsistency(input);
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems).hasSize(expectedProblems.size());
+    assertThat(checkInfo.checkAccountExternalIdsResult.problems)
+        .containsExactlyElementsIn(expectedProblems);
+  }
+
+  @Test
+  public void checkConsistencyNotAllowed() throws Exception {
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to run consistency checks");
+    gApi.config().server().checkConsistency(new ConsistencyCheckInput());
+  }
+
+  private ConsistencyProblemInfo consistencyError(String message) {
+    return new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, message);
+  }
+
+  private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
+    return ExternalId.createWithPassword(
+        ExternalId.Key.parse(externalId), admin.id, admin.email.toUpperCase(Locale.US), "password");
+  }
+
+  private String insertExternalIdWithoutAccountId(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = extId.key().sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      c.unset("externalId", extId.key().get(), "accountId");
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithKeyThatDoesntMatchNoteId(
+      Repository repo, RevWalk rw, String externalId) throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), admin.id);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+      Config c = new Config();
+      extId.writeToConfig(c);
+      byte[] raw = c.toText().getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithInvalidConfig(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "bad-config".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private String insertExternalIdWithEmptyNote(Repository repo, RevWalk rw, String externalId)
+      throws IOException {
+    ObjectId rev = ExternalIdReader.readRevision(repo);
+    NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+    try (ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+      byte[] raw = "".getBytes(UTF_8);
+      ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
+      noteMap.set(noteId, dataBlob);
+
+      ExternalIdsUpdate.commit(
+          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+      return noteId.getName();
+    }
+  }
+
+  private ExternalId createExternalIdForNonExistingAccount(String externalId) {
+    return ExternalId.create(ExternalId.Key.parse(externalId), new Account.Id(1));
+  }
+
+  private ExternalId createExternalIdWithInvalidEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, "invalid-email");
+  }
+
+  private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
+    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), admin.id, admin.email);
+  }
+
+  private ExternalId createExternalIdWithBadPassword(String username) {
+    return ExternalId.create(
+        ExternalId.Key.create(SCHEME_USERNAME, username),
+        admin.id,
+        null,
+        "non-hashed-password-is-not-allowed");
+  }
+
+  private static String nextId(MutableInteger i) {
+    return "foo:bar" + ++i.value;
+  }
+
+  @Test
   public void retryOnLockFailure() throws Exception {
     Retryer<RefsMetaExternalIdsUpdate> retryer =
         ExternalIdsUpdate.retryerBuilder()
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
new file mode 100644
index 0000000..e3b7dd3
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2017 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.extensions.api.config;
+
+import java.util.List;
+import java.util.Objects;
+
+public class ConsistencyCheckInfo {
+  public CheckAccountExternalIdsResultInfo checkAccountExternalIdsResult;
+
+  public static class CheckAccountExternalIdsResultInfo {
+    public List<ConsistencyProblemInfo> problems;
+
+    public CheckAccountExternalIdsResultInfo(List<ConsistencyProblemInfo> problems) {
+      this.problems = problems;
+    }
+  }
+
+  public static class ConsistencyProblemInfo {
+    public enum Status {
+      ERROR,
+      WARNING,
+    }
+
+    public final Status status;
+    public final String message;
+
+    public ConsistencyProblemInfo(Status status, String message) {
+      this.status = status;
+      this.message = message;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof ConsistencyProblemInfo) {
+        ConsistencyProblemInfo other = ((ConsistencyProblemInfo) o);
+        return Objects.equals(status, other.status) && Objects.equals(message, other.message);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(status, message);
+    }
+
+    @Override
+    public String toString() {
+      return status.name() + ": " + message;
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
new file mode 100644
index 0000000..170db0f
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 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.extensions.api.config;
+
+public class ConsistencyCheckInput {
+  public CheckAccountExternalIdsInput checkAccountExternalIds;
+
+  public static class CheckAccountExternalIdsInput {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
index 97f4af0..ee0960c 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/config/Server.java
@@ -34,6 +34,8 @@
 
   DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) throws RestApiException;
 
+  ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -68,5 +70,10 @@
     public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) {
       throw new NotImplementedException();
     }
+
+    @Override
+    public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 74e1fda..b4fef67 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -215,21 +215,21 @@
       throw invalidConfig(
           noteId,
           String.format(
-              "Expected exactly 1 %s section, found %d",
+              "Expected exactly 1 '%s' section, found %d",
               EXTERNAL_ID_SECTION, externalIdKeys.size()));
     }
 
     String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
     Key externalIdKey = Key.parse(externalIdKeyStr);
     if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("Invalid external id: %s", externalIdKeyStr));
+      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
     }
 
     if (!externalIdKey.sha1().getName().equals(noteId)) {
       throw invalidConfig(
           noteId,
           String.format(
-              "SHA1 of external ID %s does not match note ID %s", externalIdKeyStr, noteId));
+              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
     }
 
     String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
@@ -252,7 +252,7 @@
       throw invalidConfig(
           noteId,
           String.format(
-              "Value for %s.%s.%s is missing, expected account ID",
+              "Value for '%s.%s.%s' is missing, expected account ID",
               EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
     }
 
@@ -263,7 +263,7 @@
         throw invalidConfig(
             noteId,
             String.format(
-                "Value %s for %s.%s.%s is invalid, expected account ID",
+                "Value %s for '%s.%s.%s' is invalid, expected account ID",
                 accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
       }
       return accountId;
@@ -271,14 +271,14 @@
       throw invalidConfig(
           noteId,
           String.format(
-              "Value %s for %s.%s.%s is invalid, expected account ID",
+              "Value %s for '%s.%s.%s' is invalid, expected account ID",
               accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
     }
   }
 
   private static ConfigInvalidException invalidConfig(String noteId, String message) {
     return new ConfigInvalidException(
-        String.format("Invalid external id config for note %s: %s", noteId, message));
+        String.format("Invalid external ID config for note '%s': %s", noteId, message));
   }
 
   public static ExternalId from(AccountExternalId externalId) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
new file mode 100644
index 0000000..bc681a2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -0,0 +1,150 @@
+// Copyright (C) 2017 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.server.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.joining;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.codec.DecoderException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ExternalIdsConsistencyChecker {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final AccountCache accountCache;
+
+  @Inject
+  ExternalIdsConsistencyChecker(
+      GitRepositoryManager repoManager, AllUsersName allUsers, AccountCache accountCache) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.accountCache = accountCache;
+  }
+
+  public List<ConsistencyProblemInfo> check() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, ExternalIdReader.readRevision(repo));
+    }
+  }
+
+  public List<ConsistencyProblemInfo> check(ObjectId rev) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, rev);
+    }
+  }
+
+  private List<ConsistencyProblemInfo> check(Repository repo, ObjectId commit) throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    ListMultimap<String, ExternalId.Key> emails =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, commit);
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader()
+                .open(note.getData(), OBJ_BLOB)
+                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw);
+          problems.addAll(validateExternalId(extId));
+
+          if (extId.email() != null) {
+            emails.put(extId.email(), extId.key());
+          }
+        } catch (ConfigInvalidException e) {
+          addError(String.format(e.getMessage()), problems);
+        }
+      }
+    }
+
+    emails
+        .asMap()
+        .entrySet()
+        .stream()
+        .filter(e -> e.getValue().size() > 1)
+        .forEach(
+            e ->
+                addError(
+                    String.format(
+                        "Email '%s' is not unique, it's used by the following external IDs: %s",
+                        e.getKey(),
+                        e.getValue()
+                            .stream()
+                            .map(k -> "'" + k.get() + "'")
+                            .sorted()
+                            .collect(joining(", "))),
+                    problems));
+
+    return problems;
+  }
+
+  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    if (accountCache.getIfPresent(extId.accountId()) == null) {
+      addError(
+          String.format(
+              "External ID '%s' belongs to account that doesn't exist: %s",
+              extId.key().get(), extId.accountId().get()),
+          problems);
+    }
+
+    if (extId.email() != null && !OutgoingEmailValidator.isValid(extId.email())) {
+      addError(
+          String.format(
+              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
+          problems);
+    }
+
+    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
+      try {
+        HashedPassword.decode(extId.password());
+      } catch (DecoderException e) {
+        addError(
+            String.format(
+                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
+            problems);
+      }
+    }
+
+    return problems;
+  }
+
+  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
+    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
index 9b6ead0..d3c5135 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -15,11 +15,14 @@
 package com.google.gerrit.server.api.config;
 
 import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.CheckConsistency;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetDiffPreferences;
 import com.google.gerrit.server.config.GetPreferences;
@@ -27,6 +30,7 @@
 import com.google.gerrit.server.config.SetDiffPreferences;
 import com.google.gerrit.server.config.SetPreferences;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -38,6 +42,7 @@
   private final GetDiffPreferences getDiffPreferences;
   private final SetDiffPreferences setDiffPreferences;
   private final GetServerInfo getServerInfo;
+  private final Provider<CheckConsistency> checkConsistency;
 
   @Inject
   ServerImpl(
@@ -45,12 +50,14 @@
       SetPreferences setPreferences,
       GetDiffPreferences getDiffPreferences,
       SetDiffPreferences setDiffPreferences,
-      GetServerInfo getServerInfo) {
+      GetServerInfo getServerInfo,
+      Provider<CheckConsistency> checkConsistency) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
     this.setDiffPreferences = setDiffPreferences;
     this.getServerInfo = getServerInfo;
+    this.checkConsistency = checkConsistency;
   }
 
   @Override
@@ -104,4 +111,13 @@
       throw new RestApiException("Cannot set default diff preferences", e);
     }
   }
+
+  @Override
+  public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
+    try {
+      return checkConsistency.get().apply(new ConfigResource(), in);
+    } catch (IOException e) {
+      throw new RestApiException("Cannot check consistency", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
new file mode 100644
index 0000000..f424995
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 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.server.config;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountExternalIdsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class CheckConsistency implements RestModifyView<ConfigResource, ConsistencyCheckInput> {
+  private final Provider<IdentifiedUser> userProvider;
+  private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+
+  @Inject
+  CheckConsistency(
+      Provider<IdentifiedUser> currentUser,
+      ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+    this.userProvider = currentUser;
+    this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+  }
+
+  @Override
+  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
+      throws RestApiException, IOException {
+    IdentifiedUser user = userProvider.get();
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (!user.getCapabilities().canAccessDatabase()) {
+      throw new AuthException("not allowed to run consistency checks");
+    }
+
+    if (input == null || input.checkAccountExternalIds == null) {
+      throw new BadRequestException("input required");
+    }
+
+    ConsistencyCheckInfo consistencyCheckInfo = new ConsistencyCheckInfo();
+    if (input.checkAccountExternalIds != null) {
+      consistencyCheckInfo.checkAccountExternalIdsResult =
+          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check());
+    }
+
+    return consistencyCheckInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
index a05058e..612fea2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
@@ -36,6 +36,7 @@
     child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
     get(CONFIG_KIND, "version").to(GetVersion.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
+    post(CONFIG_KIND, "check").to(CheckConsistency.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
     get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);