diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index 9febefcb8..a2166ec15 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -294,6 +294,7 @@ import org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand; import org.whispersystems.textsecuregcm.workers.RemoveExpiredBackupsCommand; import org.whispersystems.textsecuregcm.workers.RemoveExpiredLinkedDevicesCommand; import org.whispersystems.textsecuregcm.workers.RemoveExpiredUsernameHoldsCommand; +import org.whispersystems.textsecuregcm.workers.RemoveNonSpqrAccountsCommand; import org.whispersystems.textsecuregcm.workers.RemoveNonSpqrLinkedDevicesCommand; import org.whispersystems.textsecuregcm.workers.RemoveOrphanedPreKeyPagesCommand; import org.whispersystems.textsecuregcm.workers.ScheduledApnPushNotificationSenderServiceCommand; @@ -358,6 +359,7 @@ public class WhisperServerService extends Application accounts) { + final int maxAccounts = getNamespace().getInt(MAX_ACCOUNTS_ARGUMENT); + final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT); + final boolean dryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT); + + final AccountsManager accountsManager = getCommandDependencies().accountsManager(); + + final Counter removeAccountCounterName = + Metrics.counter(REMOVE_ACCOUNT_COUNTER_NAME, "dryRun", String.valueOf(dryRun)); + + accounts + .filter(account -> !account.getPrimaryDevice().hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)) + .take(maxAccounts) + .flatMap(account -> { + final Mono removeAccountMono = dryRun + ? Mono.empty() + : Mono.fromRunnable(() -> accountsManager.delete(account, AccountsManager.DeletionReason.ADMIN_DELETED)) + .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))) + .onErrorResume(throwable -> { + logger.warn("Failed to remove account: {}", + account.getIdentifier(IdentityType.ACI), + throwable); + + return Mono.empty(); + }) + .then(); + + return removeAccountMono + .doOnSuccess(_ -> removeAccountCounterName.increment()); + }, maxConcurrency) + .then() + .block(); + } +} diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommandTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommandTest.java new file mode 100644 index 000000000..fd23395d9 --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveNonSpqrAccountsCommandTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.workers; + +import net.sourceforge.argparse4j.inf.Namespace; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.whispersystems.textsecuregcm.storage.Account; +import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.Device; +import org.whispersystems.textsecuregcm.storage.DeviceCapability; +import reactor.core.publisher.Flux; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class RemoveNonSpqrAccountsCommandTest { + + private static class TestRemoveNonSpqrAccountsCommand extends RemoveNonSpqrAccountsCommand { + + private final CommandDependencies commandDependencies; + private final Namespace namespace; + + public TestRemoveNonSpqrAccountsCommand(final int maxAccounts, final boolean isDryRun) { + + commandDependencies = mock(CommandDependencies.class); + when(commandDependencies.accountsManager()).thenReturn(mock(AccountsManager.class)); + + namespace = new Namespace(Map.of( + RemoveNonSpqrAccountsCommand.MAX_ACCOUNTS_ARGUMENT, maxAccounts, + RemoveNonSpqrAccountsCommand.DRY_RUN_ARGUMENT, isDryRun, + RemoveNonSpqrAccountsCommand.MAX_CONCURRENCY_ARGUMENT, 16)); + } + + @Override + protected CommandDependencies getCommandDependencies() { + return commandDependencies; + } + + @Override + protected Namespace getNamespace() { + return namespace; + } + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void crawlAccounts(final boolean dryRun) { + final int maxAccountsToRemove = 4; + + final List accounts = new ArrayList<>(); + + for (int i = 0; i < maxAccountsToRemove * 2; i++) { + accounts.add(buildMockAccount(true)); + } + + for (int i = 0; i < maxAccountsToRemove * 2; i++) { + accounts.add(buildMockAccount(false)); + } + + Collections.shuffle(accounts); + + final RemoveNonSpqrAccountsCommand removeNonSpqrAccountsCommand = + new TestRemoveNonSpqrAccountsCommand(maxAccountsToRemove, dryRun); + + removeNonSpqrAccountsCommand.crawlAccounts(Flux.fromIterable(accounts)); + + final AccountsManager accountsManager = removeNonSpqrAccountsCommand.getCommandDependencies().accountsManager(); + + if (dryRun) { + verify(accountsManager, never()).delete(any(), any()); + } else { + verify(accountsManager, times(maxAccountsToRemove)).delete( + argThat(account -> !account.getPrimaryDevice().hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)), + eq(AccountsManager.DeletionReason.ADMIN_DELETED)); + + verifyNoMoreInteractions(accountsManager); + } + } + + private static Account buildMockAccount(final boolean supportsSpqr) { + final Device primaryDevice = mock(Device.class); + when(primaryDevice.hasCapability(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET)).thenReturn(supportsSpqr); + + final Account account = mock(Account.class); + when(account.getPrimaryDevice()).thenReturn(primaryDevice); + + return account; + } +}