Merge "Add API method for deleting self account"
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 2d5ab1d..275006f9 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -1082,6 +1082,7 @@
[[bouncycastle]]
bouncycastle
+* bouncycastle:bcpg
* bouncycastle:bcpg-neverlink
* bouncycastle:bcpkix-neverlink
* bouncycastle:bcprov-neverlink
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 7646777..64bdce0 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -186,6 +186,27 @@
}
----
+[[delete-account]]
+=== Delete Account
+--
+'DELETE /accounts/link:#account-id[\{account-id\}]'
+--
+
+Deletes the given account.
+
+Currently only supporting self deletion (regardless of the way
+link:#account-id[\{account-id\}] is provided).
+
+.Request
+----
+ DELETE /accounts/self HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 204 No Content
+----
+
[[get-detail]]
=== Get Account Details
--
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index e93c152..28ef0ed 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -293,6 +293,7 @@
protected TestAccount admin;
protected TestAccount user;
protected TestRepository<InMemoryRepository> testRepo;
+ protected String testMethodName;
protected String resourcePrefix;
protected Description description;
protected GerritServer.Description testMethodDescription;
@@ -479,9 +480,10 @@
initSsh();
+ testMethodName = description.getMethodName();
resourcePrefix =
UNSAFE_PROJECT_NAME
- .matcher(description.getClassName() + "_" + description.getMethodName() + "_")
+ .matcher(description.getClassName() + "_" + testMethodName + "_")
.replaceAll("");
Context ctx = newRequestContext(admin);
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index ff5bc00..310d141 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -166,6 +166,10 @@
accounts.get(username), () -> String.format("No TestAccount created for %s ", username));
}
+ public void evict(Account.Id id) {
+ evict(ImmutableSet.of(id));
+ }
+
public void evict(Collection<Account.Id> ids) {
accounts.values().removeIf(a -> ids.contains(a.id()));
}
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 9c9c282..0d019aa 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -124,6 +124,8 @@
*/
String setHttpPassword(String httpPassword) throws RestApiException;
+ void delete() throws RestApiException;
+
/**
* A default implementation which allows source compatibility when adding new methods to the
* interface.
@@ -327,5 +329,10 @@
public String setHttpPassword(String httpPassword) throws RestApiException {
throw new NotImplementedException();
}
+
+ @Override
+ public void delete() throws RestApiException {
+ throw new NotImplementedException();
+ }
}
}
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java
index bd88962..6885d84 100644
--- a/java/com/google/gerrit/git/RefUpdateUtil.java
+++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -147,18 +147,18 @@
* occurs.
* @throws IOException if an error occurred.
*/
- public static void deleteChecked(Repository repo, String refName) throws IOException {
+ public static RefUpdate deleteChecked(Repository repo, String refName) throws IOException {
RefUpdate ru = repo.updateRef(refName);
ru.setForceUpdate(true);
ru.setCheckConflicting(false);
switch (ru.delete()) {
case FORCED:
// Ref was deleted.
- return;
+ return ru;
case NEW:
// Ref didn't exist (yes, really).
- return;
+ return ru;
case LOCK_FAILURE:
throw new LockFailureException("Failed to delete " + refName + ": " + ru.getResult(), ru);
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index fcf4f0f..3958821 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -4,6 +4,9 @@
name = "gpg",
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
+ runtime_deps = [
+ "//lib/bouncycastle:bcpg",
+ ],
deps = [
"//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
diff --git a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
index 2c1cae6..1140bcd 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStoreUtil.java
@@ -107,4 +107,9 @@
}
return res;
}
+
+ public List<RefUpdate.Result> deleteAllPgpKeysForUser(
+ Account.Id id, PersonIdent committer, PersonIdent author) throws PGPException, IOException {
+ return deletePgpKeys(listGpgKeysForUser(id), committer, author);
+ }
}
diff --git a/java/com/google/gerrit/gpg/SignedPushModule.java b/java/com/google/gerrit/gpg/SignedPushModule.java
index f4fb9f9..98487ca 100644
--- a/java/com/google/gerrit/gpg/SignedPushModule.java
+++ b/java/com/google/gerrit/gpg/SignedPushModule.java
@@ -33,6 +33,7 @@
import com.google.inject.Provider;
import com.google.inject.ProvisionException;
import com.google.inject.Singleton;
+import com.google.inject.multibindings.OptionalBinder;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
@@ -54,7 +55,12 @@
if (!BouncyCastleUtil.havePGP()) {
throw new ProvisionException("Bouncy Castle PGP not installed");
}
- bind(PublicKeyStore.class).toProvider(StoreProvider.class);
+ // This binding is optional as some modules might bind
+ // {@code UnimplementedPublicKeyStoreProvider} as default binding.
+ OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+ .setBinding()
+ .toProvider(StoreProvider.class);
+
DynamicSet.bind(binder(), ReceivePackInitializer.class).to(Initializer.class);
}
diff --git a/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
new file mode 100644
index 0000000..12e8edb
--- /dev/null
+++ b/java/com/google/gerrit/gpg/UnimplementedPublicKeyStoreProvider.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2023 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.gpg;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class UnimplementedPublicKeyStoreProvider implements Provider<PublicKeyStore> {
+ @Override
+ public PublicKeyStore get() {
+ throw new NotImplementedException("UnimplementedPublicKeyStoreProvider was bound.");
+ }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index cf04029..8b41356 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -294,6 +294,10 @@
}
public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
+ return byAccountId(accountId, true);
+ }
+
+ public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, boolean skipInvalidChanges) {
try (Repository repo = repoManager.openRepository(allUsers)) {
ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
@@ -310,7 +314,7 @@
// Skip invalid change ids.
Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
- if (changeId == null) {
+ if (skipInvalidChanges && changeId == null) {
continue;
}
builder.add(changeId);
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index 8f285b5..30f879e 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -161,6 +161,15 @@
*/
public abstract Optional<EditPreferencesInfo> getEditPreferences();
+ /**
+ * Returns whether the delta for this account is deleting the account.
+ *
+ * <p>If set to true, deletion takes precedence on any other change in this delta.
+ *
+ * @return whether the account should be deleted.
+ */
+ public abstract Optional<Boolean> getShouldDeleteAccount();
+
public boolean hasExternalIdUpdates() {
return !this.getCreatedExternalIds().isEmpty()
|| !this.getDeletedExternalIds().isEmpty()
@@ -452,6 +461,20 @@
*/
public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
+ public abstract Builder setShouldDeleteAccount(boolean shouldDelete);
+
+ /**
+ * Builds an AccountDelta that deletes all data.
+ *
+ * @param extIdsToDelete external IDs that should be deleted
+ * @return the builder
+ */
+ public Builder deleteAccount(Collection<ExternalId> extIdsToDelete) {
+ deleteExternalIds(extIdsToDelete);
+ setShouldDeleteAccount(true);
+ return this;
+ }
+
/** Builds the instance. */
public abstract AccountDelta build();
@@ -602,6 +625,12 @@
delegate.setEditPreferences(editPreferences);
return this;
}
+
+ @Override
+ public Builder setShouldDeleteAccount(boolean shouldDelete) {
+ delegate.setShouldDeleteAccount(shouldDelete);
+ return this;
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index b706bca..7dd4828 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -25,6 +25,7 @@
import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
@@ -34,6 +35,7 @@
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIdNotes;
import com.google.gerrit.server.account.externalids.ExternalIdNotes.ExternalIdNotesLoader;
import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -53,6 +55,7 @@
import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -62,8 +65,10 @@
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
/**
* Creates and updates accounts.
@@ -380,6 +385,22 @@
.get(0);
}
+ /**
+ * Deletes all the account state data.
+ *
+ * @param message commit message for the account update, must not be {@code null or empty}
+ * @param accountId ID of the account
+ * @throws IOException if updating the user branch fails due to an IO error
+ * @throws ConfigInvalidException if any of the account fields has an invalid value
+ */
+ public void delete(String message, Account.Id accountId)
+ throws IOException, ConfigInvalidException {
+ ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
+ Consumer<AccountDelta.Builder> delta =
+ deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
+ update(message, accountId, delta);
+ }
+
private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
return repo -> {
AccountConfig accountConfig = read(repo, updateArguments.accountId);
@@ -395,7 +416,7 @@
updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
AccountDelta delta = deltaBuilder.build();
- accountConfig.setAccountDelta(delta);
+
ExternalIdNotes.checkSameAccount(
Iterables.concat(
delta.getCreatedExternalIds(),
@@ -415,9 +436,13 @@
externalIdNotes.upsert(delta.getUpdatedExternalIds());
}
+ if (delta.getShouldDeleteAccount().orElse(false)) {
+ return new DeletedAccount(updateArguments.message, accountConfig.getRefName());
+ }
+
+ accountConfig.setAccountDelta(delta);
CachedPreferences cachedDefaultPreferences =
CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
-
return new UpdatedAccount(
updateArguments.message, accountConfig, cachedDefaultPreferences, false);
};
@@ -468,7 +493,8 @@
allUsersRepo,
updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
for (UpdatedAccount ua : updatedAccounts) {
- accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
+ accountState.add(
+ ua == null || ua.deleted ? Optional.empty() : ua.getAccountState());
}
}
return null;
@@ -514,6 +540,7 @@
beforeCommit.run();
BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+ Set<Account.Id> accountsToSkipForReindex = new HashSet<>();
// External ids may be not updated if:
// * externalIdNotes is not loaded (there were no externalId updates in the delta)
// * new revCommit is identical to the previous externalId tip
@@ -531,7 +558,12 @@
externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
}
for (UpdatedAccount updatedAccount : updatedAccounts) {
-
+ if (updatedAccount.deleted) {
+ RefUpdate ru = RefUpdateUtil.deleteChecked(allUsersRepo, updatedAccount.refName);
+ gitRefUpdated.fire(allUsersName, ru, ReceiveCommand.Type.DELETE, null);
+ accountsToSkipForReindex.add(Account.Id.fromRef(updatedAccount.refName));
+ continue;
+ }
// These updates are all for different refs (because batches never update the same account
// more than once), so there can be multiple commits in the same batch, all with the same base
// revision in their AccountConfig.
@@ -540,7 +572,7 @@
// when no account properties are set and hence no
// 'account.config' file will be created.
// 2) When updating "refs/meta/external-ids", so that refs/users/* meta ref is updated too.
- // This allows to schedule reindexing of account transactionally on refs/users/* meta
+ // This allows to schedule reindexing of account transactionally on refs/users/* meta
// updates.
boolean allowEmptyCommit = externalIdsUpdated || updatedAccount.created;
commitAccountConfig(
@@ -553,8 +585,8 @@
RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
- Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
if (externalIdsUpdated) {
+ accountsToSkipForReindex.addAll(getUpdatedAccountIds(batchRefUpdate));
extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
externalIdNotes, accountsToSkipForReindex);
}
@@ -615,18 +647,38 @@
final String message;
final AccountConfig accountConfig;
final CachedPreferences defaultPreferences;
+ final String refName;
final boolean created;
+ final boolean deleted;
UpdatedAccount(
String message,
AccountConfig accountConfig,
CachedPreferences defaultPreferences,
boolean created) {
+ this(
+ message,
+ requireNonNull(accountConfig),
+ defaultPreferences,
+ accountConfig.getRefName(),
+ created,
+ false);
+ }
+
+ protected UpdatedAccount(
+ String message,
+ AccountConfig accountConfig,
+ CachedPreferences defaultPreferences,
+ String refName,
+ boolean created,
+ boolean deleted) {
checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
this.message = requireNonNull(message);
- this.accountConfig = requireNonNull(accountConfig);
+ this.accountConfig = accountConfig;
this.defaultPreferences = defaultPreferences;
+ this.refName = refName;
this.created = created;
+ this.deleted = deleted;
}
Optional<AccountState> getAccountState() throws IOException {
@@ -634,4 +686,10 @@
externalIds, accountConfig, externalIdNotes, defaultPreferences);
}
}
+
+ private class DeletedAccount extends UpdatedAccount {
+ DeletedAccount(String message, String refName) {
+ super(message, null, null, refName, false, true);
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 828f868..400521b 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -54,6 +54,7 @@
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.restapi.account.AddSshKey;
import com.google.gerrit.server.restapi.account.CreateEmail;
+import com.google.gerrit.server.restapi.account.DeleteAccount;
import com.google.gerrit.server.restapi.account.DeleteActive;
import com.google.gerrit.server.restapi.account.DeleteDraftComments;
import com.google.gerrit.server.restapi.account.DeleteEmail;
@@ -135,6 +136,7 @@
private final EmailApiImpl.Factory emailApi;
private final PutName putName;
private final PutHttpPassword putHttpPassword;
+ private final DeleteAccount deleteAccount;
@Inject
AccountApiImpl(
@@ -176,6 +178,7 @@
EmailApiImpl.Factory emailApi,
PutName putName,
PutHttpPassword putPassword,
+ DeleteAccount deleteAccount,
@Assisted AccountResource account) {
this.account = account;
this.accountLoaderFactory = ailf;
@@ -216,6 +219,7 @@
this.emailApi = emailApi;
this.putName = putName;
this.putHttpPassword = putPassword;
+ this.deleteAccount = deleteAccount;
}
@Override
@@ -604,4 +608,13 @@
throw asRestApiException("Cannot generate HTTP password", e);
}
}
+
+ @Override
+ public void delete() throws RestApiException {
+ try {
+ deleteAccount.apply(account, null);
+ } catch (Exception e) {
+ throw asRestApiException("Cannot delete account " + account.getUser().getNameEmail(), e);
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
index 1b9008d..dc34095 100644
--- a/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/change/AccountPatchReviewStore.java
@@ -19,6 +19,7 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
import java.util.Collection;
import java.util.Optional;
@@ -91,6 +92,16 @@
void clearReviewed(Change.Id changeId);
/**
+ * Clears the reviewed flags for the given user in all the relevant changes/patch-set/files.
+ *
+ * @param accountId account ID of the user
+ */
+ default void clearReviewedBy(Account.Id accountId) {
+ throw new NotImplementedException(
+ "clearReviewedBy() is not implemented for this AccountPatchReviewStore.");
+ }
+
+ /**
* Find the latest patch set, that is smaller or equals to the given patch set, where at least,
* one file has been reviewed by the given user.
*
diff --git a/java/com/google/gerrit/server/edit/ChangeEdit.java b/java/com/google/gerrit/server/edit/ChangeEdit.java
index c652289..d7a3a11 100644
--- a/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -18,6 +18,7 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.revwalk.RevCommit;
/**
@@ -53,6 +54,10 @@
return editCommit;
}
+ public ObjectId getEditCommitId() {
+ return editCommit.getId();
+ }
+
public PatchSet getBasePatchSet() {
return basePatchSet;
}
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 3594afb..c09dac3 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -250,7 +250,7 @@
try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
String refName = edit.getRefName();
RefUpdate ru = repo.updateRef(refName, true);
- ru.setExpectedOldObjectId(edit.getEditCommit());
+ ru.setExpectedOldObjectId(edit.getEditCommitId());
ru.setForceUpdate(true);
RefUpdate.Result result = ru.delete();
switch (result) {
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 62c070c..76ee627 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -27,6 +27,7 @@
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
@@ -73,6 +74,10 @@
return ChangeStatusPredicate.forStatus(status);
}
+ private static Predicate<ChangeData> editBy(Account.Id accountId) {
+ return ChangePredicates.editBy(accountId);
+ }
+
private static Predicate<ChangeData> commit(String id) {
return ChangePredicates.commitPrefix(id);
}
@@ -210,6 +215,10 @@
return query(and(ChangePredicates.exactTopic(topic), open()));
}
+ public List<ChangeData> byOpenEditByUser(Account.Id accountId) {
+ return query(editBy(accountId));
+ }
+
public List<ChangeData> byCommit(ObjectId id) {
return byCommit(id.name());
}
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index dd0ec78d..a153399 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -15,6 +15,7 @@
"//java/com/google/gerrit/exceptions",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/git",
+ "//java/com/google/gerrit/gpg",
"//java/com/google/gerrit/index",
"//java/com/google/gerrit/index:query_exception",
"//java/com/google/gerrit/index/project",
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index 2a8f55f..af93b8a 100644
--- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -23,18 +23,24 @@
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.gpg.PublicKeyStore;
+import com.google.gerrit.gpg.UnimplementedPublicKeyStoreProvider;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.ServerInitiated;
import com.google.gerrit.server.UserInitiated;
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.externalids.ExternalIdNotes;
import com.google.inject.Provides;
+import com.google.inject.multibindings.OptionalBinder;
public class AccountRestApiModule extends RestApiModule {
@Override
protected void configure() {
bind(AccountsCollection.class);
bind(Capabilities.class);
+ OptionalBinder.newOptionalBinder(binder(), PublicKeyStore.class)
+ .setDefault()
+ .toProvider(UnimplementedPublicKeyStoreProvider.class);
DynamicMap.mapOf(binder(), ACCOUNT_KIND);
DynamicMap.mapOf(binder(), CAPABILITY_KIND);
@@ -45,6 +51,7 @@
create(ACCOUNT_KIND).to(CreateAccount.class);
put(ACCOUNT_KIND).to(PutAccount.class);
+ delete(ACCOUNT_KIND).to(DeleteAccount.class);
get(ACCOUNT_KIND).to(GetAccount.class);
get(ACCOUNT_KIND, "detail").to(GetDetail.class);
post(ACCOUNT_KIND, "index").to(Index.class);
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteAccount.java b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
new file mode 100644
index 0000000..e5b7fd2
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteAccount.java
@@ -0,0 +1,192 @@
+// Copyright (C) 2023 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.restapi.account;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Table;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.gpg.PublicKeyStoreUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.account.AccountException;
+import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * REST endpoint for deleting an account.
+ *
+ * <p>This REST endpoint handles {@code DELETE /accounts/<account-identifier>} requests. Currently,
+ * only self deletions are allowed.
+ */
+@Singleton
+public class DeleteAccount implements RestModifyView<AccountResource, Input> {
+ private final Provider<CurrentUser> self;
+ private final Provider<PersonIdent> serverIdent;
+ private final Provider<AccountsUpdate> accountsUpdateProvider;
+ private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+ private final SshKeyCache sshKeyCache;
+ private final StarredChangesUtil starredChangesUtil;
+ private final DeleteDraftCommentsUtil deleteDraftCommentsUtil;
+ private final GitRepositoryManager gitManager;
+ private final Provider<InternalChangeQuery> queryProvider;
+ private final ChangeEditUtil changeEditUtil;
+ private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+ private final Provider<PublicKeyStoreUtil> publicKeyStoreUtilProvider;
+
+ @Inject
+ public DeleteAccount(
+ Provider<CurrentUser> self,
+ @GerritPersonIdent Provider<PersonIdent> serverIdent,
+ @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+ VersionedAuthorizedKeys.Accessor authorizedKeys,
+ SshKeyCache sshKeyCache,
+ StarredChangesUtil starredChangesUtil,
+ DeleteDraftCommentsUtil deleteDraftCommentsUtil,
+ GitRepositoryManager gitManager,
+ Provider<InternalChangeQuery> queryProvider,
+ ChangeEditUtil changeEditUtil,
+ PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+ Provider<PublicKeyStoreUtil> publicKeyStoreUtilProvider) {
+ this.self = self;
+ this.serverIdent = serverIdent;
+ this.accountsUpdateProvider = accountsUpdateProvider;
+ this.authorizedKeys = authorizedKeys;
+ this.sshKeyCache = sshKeyCache;
+ this.starredChangesUtil = starredChangesUtil;
+ this.deleteDraftCommentsUtil = deleteDraftCommentsUtil;
+ this.gitManager = gitManager;
+ this.queryProvider = queryProvider;
+ this.changeEditUtil = changeEditUtil;
+ this.accountPatchReviewStore = accountPatchReviewStore;
+ this.publicKeyStoreUtilProvider = publicKeyStoreUtilProvider;
+ }
+
+ @Override
+ public Response<?> apply(AccountResource rsrc, Input unusedInput)
+ throws AuthException, AccountException {
+ IdentifiedUser user = rsrc.getUser();
+ if (!self.get().hasSameAccountId(user)) {
+ throw new AuthException("Delete account is only permitted for self");
+ }
+
+ Account.Id userId = user.getAccountId();
+ try {
+ deletePgpKeys(user);
+ deleteSshKeys(user);
+ deleteStarredChanges(userId);
+ deleteChangeEdits(userId);
+ deleteDraftCommentsUtil.deleteDraftComments(user, null);
+ accountPatchReviewStore.run(a -> a.clearReviewedBy(userId));
+ accountsUpdateProvider
+ .get()
+ .delete("Deleting user through `DELETE /accounts/{ID}`", user.getAccountId());
+ } catch (Exception e) {
+ throw new AccountException("Could not delete account", e);
+ }
+ return Response.none();
+ }
+
+ private void deletePgpKeys(IdentifiedUser user) {
+ if (publicKeyStoreUtilProvider == null || publicKeyStoreUtilProvider.get() == null) {
+ return;
+ }
+ try {
+ PublicKeyStoreUtil storeUtil = publicKeyStoreUtilProvider.get();
+ List<RefUpdate.Result> deletedKeyResults =
+ storeUtil.deleteAllPgpKeysForUser(
+ user.getAccountId(), serverIdent.get(), serverIdent.get());
+ for (RefUpdate.Result saveResult : deletedKeyResults) {
+ if (saveResult != RefUpdate.Result.NO_CHANGE
+ && saveResult != RefUpdate.Result.FAST_FORWARD) {
+ throw new StorageException(String.format("Failed to delete PGP key: %s", saveResult));
+ }
+ }
+ } catch (Exception e) {
+ throw new StorageException("Failed to delete PGP keys.", e);
+ }
+ }
+
+ private void deleteSshKeys(IdentifiedUser user) throws ConfigInvalidException, IOException {
+ List<AccountSshKey> keys = authorizedKeys.getKeys(user.getAccountId());
+ for (AccountSshKey key : keys) {
+ authorizedKeys.deleteKey(user.getAccountId(), key.seq());
+ }
+ user.getUserName().ifPresent(sshKeyCache::evict);
+ }
+
+ private void deleteStarredChanges(Account.Id accountId)
+ throws StarredChangesUtil.IllegalLabelException {
+ ImmutableSet<Change.Id> staredChanges = starredChangesUtil.byAccountId(accountId, false);
+ for (Change.Id change : staredChanges) {
+ starredChangesUtil.star(
+ self.get().getAccountId(), change, StarredChangesUtil.Operation.REMOVE);
+ }
+ }
+
+ private void deleteChangeEdits(Account.Id accountId) throws IOException {
+ // Note: in case of a stale index, the results of this query might be incomplete.
+ List<ChangeData> changesWithEdits = queryProvider.get().byOpenEditByUser(accountId);
+
+ for (ChangeData cd : changesWithEdits) {
+ for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : cd.editRefs().cellSet()) {
+ if (!accountId.equals(edit.getRowKey())) {
+ continue;
+ }
+ try (Repository repo = gitManager.openRepository(cd.project());
+ RevWalk rw = new RevWalk(repo)) {
+ RevCommit commit = rw.parseCommit(edit.getValue().getObjectId());
+ changeEditUtil.delete(
+ new ChangeEdit(
+ cd.change(),
+ edit.getValue().getName(),
+ commit,
+ cd.patchSet(edit.getColumnKey())));
+ }
+ }
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
index 9e02592..336d5a4 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -89,7 +89,7 @@
}
public ImmutableList<DeletedDraftCommentInfo> deleteDraftComments(
- IdentifiedUser user, String query) throws RestApiException, UpdateException {
+ IdentifiedUser user, @Nullable String query) throws RestApiException, UpdateException {
CommentJson.HumanCommentFormatter humanCommentFormatter =
commentJsonProvider.get().newHumanCommentFormatter();
Account.Id accountId = user.getAccountId();
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 189d448..35b9893 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -343,6 +343,22 @@
}
@Override
+ public void clearReviewedBy(Account.Id accountId) {
+ try (TraceTimer ignored =
+ TraceContext.newTimer(
+ "Clear all reviewed flags by user",
+ Metadata.builder().accountId(accountId.get()).build());
+ Connection con = ds.getConnection();
+ PreparedStatement stmt =
+ con.prepareStatement("DELETE FROM account_patch_reviews WHERE account_id = ?")) {
+ stmt.setInt(1, accountId.get());
+ stmt.executeUpdate();
+ } catch (SQLException e) {
+ throw convertError("delete", e);
+ }
+ }
+
+ @Override
public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
try (TraceTimer ignored =
TraceContext.newTimer(
diff --git a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
index 1533aeb..0c612f0 100644
--- a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
@@ -117,6 +117,19 @@
}
@Override
+ public void clearReviewedBy(Account.Id accountId) {
+ synchronized (store) {
+ List<Entity> toRemove = new ArrayList<>();
+ for (Entity entity : store) {
+ if (entity.accountId().equals(accountId)) {
+ toRemove.add(entity);
+ }
+ }
+ store.removeAll(toRemove);
+ }
+ }
+
+ @Override
public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
synchronized (store) {
int matchedPsNumber = -1;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index dd04200..2c65019 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -83,6 +83,7 @@
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Account;
@@ -145,6 +146,7 @@
import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
import com.google.gerrit.server.account.externalids.ExternalIdNotes;
import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
import com.google.gerrit.server.config.AuthConfig;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -175,6 +177,8 @@
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -255,6 +259,8 @@
@Inject protected GroupOperations groupOperations;
+ @Inject private AccountPatchReviewStore accountPatchReviewStore;
+
private BasicCookieStore httpCookieStore;
private CloseableHttpClient httpclient;
@@ -3224,6 +3230,214 @@
}
}
+ @Test
+ public void deleteAccount_deletesAccountIdentifiers() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ String secondaryEmail = "secondary@email.com";
+ gApi.accounts().id(deleted.id().get()).addEmail(newEmailInput(secondaryEmail));
+
+ requestScopeOperations.setApiUser(deleted.id());
+ gApi.accounts().self().delete();
+
+ requestScopeOperations.setApiUser(admin.id());
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(deleted.id().get()).get());
+ assertThrows(NoSuchElementException.class, () -> accountCache.get(deleted.id()).get());
+
+ // Verifies the account is not queryable
+ assertThat(gApi.accounts().query(deleted.id().toString()).get()).isEmpty();
+ assertThat(gApi.accounts().query(deleted.username()).get()).isEmpty();
+ assertThat(gApi.accounts().query(deleted.fullName()).get()).isEmpty();
+ assertThat(gApi.accounts().query(deleted.displayName()).get()).isEmpty();
+ assertThat(gApi.accounts().query(deleted.email()).get()).isEmpty();
+ assertThat(gApi.accounts().query(secondaryEmail).get()).isEmpty();
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_deletesAccountExternalIds() throws Exception {
+ TestAccount deleted =
+ accountCreator.create("deleted", "deleted@internal.com", "Full Name", "Display");
+ requestScopeOperations.setApiUser(deleted.id());
+ addExternalIdEmail(deleted, "deleted@external.com");
+ assertExternalEmails(
+ deleted.id(), ImmutableSet.of("deleted@internal.com", "deleted@external.com"));
+
+ gApi.accounts().self().delete();
+
+ requestScopeOperations.setApiUser(admin.id());
+ assertThat(externalIds.allByEmail().keySet())
+ .containsNoneOf("deleted@internal.com", "deleted@external.com");
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ @UseSsh
+ public void deleteAccount_deletesSshKeys() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ requestScopeOperations.setApiUser(deleted.id());
+ String newKey = TestSshKeys.publicKey(SshSessionFactory.genSshKey(), deleted.email());
+ gApi.accounts().self().addSshKey(newKey);
+ assertThat(gApi.accounts().self().listSshKeys()).hasSize(1);
+ assertThat(authorizedKeys.getKeys(deleted.id())).hasSize(1);
+
+ gApi.accounts().self().delete();
+
+ requestScopeOperations.setApiUser(admin.id());
+ assertThat(authorizedKeys.getKeys(deleted.id())).isEmpty();
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_deletesGpgKeys() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+
+ requestScopeOperations.setApiUser(deleted.id());
+ addExternalIdEmail(
+ deleted,
+ PushCertificateIdent.parse(validKeyWithoutExpiration().getFirstUserId()).getEmailAddress());
+ TestKey key = validKeyWithoutExpiration();
+ addGpgKey(deleted, key.getPublicKeyArmored());
+ assertKeys(key);
+ assertIteratorSize(1, getOnlyKeyFromStore(key).getUserIDs());
+
+ gApi.accounts().self().delete();
+
+ requestScopeOperations.setApiUser(admin.id());
+ try (PublicKeyStore store = publicKeyStoreProvider.get()) {
+ Iterable<PGPPublicKeyRing> keys = store.get(key.getKeyId());
+ assertThat(keys).isEmpty();
+ }
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_deletesStarredChanges() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ PushOneCommit.Result r = createChange();
+ String triplet = project.get() + "~master~" + r.getChangeId();
+
+ requestScopeOperations.setApiUser(deleted.id());
+
+ gApi.accounts().self().starChange(triplet);
+ try (Repository repo = repoManager.openRepository(allUsers)) {
+ assertThat(
+ repo.getRefDatabase()
+ .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
+ .hasSize(1);
+
+ gApi.accounts().self().delete();
+
+ assertThat(
+ repo.getRefDatabase()
+ .getRefsByPrefix(RefNames.refsStarredChangesPrefix(r.getChange().getId())))
+ .isEmpty();
+ }
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_deletesChangeEdits() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ PushOneCommit.Result r = createChange();
+
+ requestScopeOperations.setApiUser(deleted.id());
+
+ gApi.changes().id(r.getChangeId()).edit().create();
+ gApi.changes()
+ .id(r.getChangeId())
+ .edit()
+ .modifyFile(PushOneCommit.FILE_NAME, RawInputUtil.create("foo".getBytes(UTF_8)));
+ try (Repository repo = repoManager.openRepository(r.getChange().change().getProject())) {
+ assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsEditPrefix(deleted.id())))
+ .hasSize(1);
+
+ gApi.accounts().self().delete();
+
+ assertThat(repo.getRefDatabase().getRefsByPrefix(RefNames.refsEditPrefix(deleted.id())))
+ .isEmpty();
+ }
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_deletesDraftComments() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ PushOneCommit.Result r = createChange();
+
+ requestScopeOperations.setApiUser(deleted.id());
+
+ createDraft(r, PushOneCommit.FILE_NAME, "draft");
+ try (Repository repo = repoManager.openRepository(allUsers)) {
+ assertThat(
+ repo.getRefDatabase()
+ .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
+ .hasSize(1);
+
+ gApi.accounts().self().delete();
+
+ assertThat(
+ repo.getRefDatabase()
+ .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(r.getChange().getId())))
+ .isEmpty();
+ }
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_deletesReviewedFlags() throws Exception {
+ PushOneCommit.Result r = createChange();
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ ReviewerInput in = new ReviewerInput();
+ in.reviewer = deleted.email();
+ gApi.changes().id(r.getChangeId()).addReviewer(in);
+
+ requestScopeOperations.setApiUser(deleted.id());
+
+ accountPatchReviewStore.markReviewed(r.getPatchSetId(), deleted.id(), PushOneCommit.FILE_NAME);
+ assertThat(accountPatchReviewStore.findReviewed(r.getPatchSetId(), deleted.id())).isPresent();
+
+ gApi.accounts().self().delete();
+
+ assertThat(accountPatchReviewStore.findReviewed(r.getPatchSetId(), deleted.id())).isEmpty();
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_appliesForSelfById() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ requestScopeOperations.setApiUser(deleted.id());
+ gApi.accounts().id(deleted.id().get()).delete();
+
+ // Clean up the test framework
+ accountCreator.evict(deleted.id());
+ }
+
+ @Test
+ public void deleteAccount_throwsForOtherUsers() throws Exception {
+ TestAccount deleted = accountCreator.createValid(testMethodName);
+ requestScopeOperations.setApiUser(user.id());
+ AuthException thrown =
+ assertThrows(AuthException.class, () -> gApi.accounts().id(deleted.id().get()).delete());
+ assertThat(thrown).hasMessageThat().isEqualTo("Delete account is only permitted for self");
+ }
+
private TestGroupBackend createTestGroupBackendWithAllUsersGroup(String nameOfAllUsersGroup)
throws IOException {
TestGroupBackend testGroupBackend = new TestGroupBackend();
@@ -3281,6 +3495,16 @@
.isEqualTo(extIds);
}
+ private void assertExternalEmails(Account.Id accountId, ImmutableSet<String> extIds)
+ throws Exception {
+ assertThat(
+ gApi.accounts().id(accountId.get()).getExternalIds().stream()
+ .map(e -> e.emailAddress)
+ .filter(Objects::nonNull)
+ .collect(toImmutableSet()))
+ .isEqualTo(extIds);
+ }
+
private static Correspondence<GroupInfo, String> getGroupToNameCorrespondence() {
return NullAwareCorrespondence.transforming(groupInfo -> groupInfo.name, "has name");
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
index 13353bd..eed4d63 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
@@ -97,7 +97,9 @@
RestCall.get("/accounts/%s/capabilities"),
RestCall.get("/accounts/%s/capabilities/viewPlugins"),
RestCall.get("/accounts/%s/gpgkeys"),
- RestCall.post("/accounts/%s/gpgkeys"));
+ RestCall.post("/accounts/%s/gpgkeys"),
+ // Account deletion must be the last tested endpoint
+ RestCall.delete("/accounts/%s"));
/**
* Email REST endpoints to be tested, each URL contains a placeholders for the account and email