diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java index e0da5dd1f..03dde46c2 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java @@ -637,8 +637,8 @@ public class WhisperServerService extends Application sourceNumber; + final Optional sourceAci; + final Optional sourcePni; + if (source.startsWith("+")) { + sourceNumber = Optional.of(source); + final Optional maybeAccount = accountsManager.getByE164(source); + if (maybeAccount.isPresent()) { + sourceAci = maybeAccount.map(Account::getUuid); + sourcePni = maybeAccount.map(Account::getPhoneNumberIdentifier); + } else { + sourceAci = deletedAccountsManager.findDeletedAccountAci(source); + sourcePni = Optional.ofNullable(accountsManager.getPhoneNumberIdentifier(source)); + } + } else { + sourceAci = Optional.of(UUID.fromString(source)); + + final Optional sourceAccount = accountsManager.getByAccountIdentifier(sourceAci.get()); + + if (sourceAccount.isEmpty()) { + logger.warn("Could not find source: {}", sourceAci.get()); + sourceNumber = deletedAccountsManager.findDeletedAccountE164(sourceAci.get()); + sourcePni = sourceNumber.map(accountsManager::getPhoneNumberIdentifier); + } else { + sourceNumber = sourceAccount.map(Account::getNumber); + sourcePni = sourceAccount.map(Account::getPhoneNumberIdentifier); + } + } + + reportMessageManager.report(sourceNumber, sourceAci, sourcePni, messageGuid, auth.getAccount().getUuid()); return Response.status(Status.ACCEPTED) .build(); diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java index 966d62806..8205fa013 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java @@ -513,6 +513,14 @@ public class AccountsManager { } } + public Optional getNumberForPhoneNumberIdentifier(UUID pni) { + return phoneNumberIdentifiers.getPhoneNumber(pni); + } + + public UUID getPhoneNumberIdentifier(String e164) { + return phoneNumberIdentifiers.getPhoneNumberIdentifier(e164); + } + public AccountCrawlChunk getAllFromDynamo(int length) { return accounts.getAllFromStart(length); } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccounts.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccounts.java index 116e25f74..72a0cf46f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccounts.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccounts.java @@ -21,14 +21,16 @@ import java.util.stream.Collectors; import org.whispersystems.textsecuregcm.util.AttributeValues; import org.whispersystems.textsecuregcm.util.Pair; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; -import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; -import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.BatchGetItemRequest; import software.amazon.awssdk.services.dynamodb.model.BatchGetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; +import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; import software.amazon.awssdk.services.dynamodb.model.KeysAndAttributes; import software.amazon.awssdk.services.dynamodb.model.PutItemRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import software.amazon.awssdk.services.dynamodb.model.ScanRequest; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; @@ -40,6 +42,8 @@ public class DeletedAccounts extends AbstractDynamoDbStore { static final String ATTR_EXPIRES = "E"; static final String ATTR_NEEDS_CDS_RECONCILIATION = "R"; + static final String UUID_TO_E164_INDEX_NAME = "u_to_p"; + static final Duration TIME_TO_LIVE = Duration.ofDays(30); // Note that this limit is imposed by DynamoDB itself; going above 100 will result in errors @@ -76,6 +80,28 @@ public class DeletedAccounts extends AbstractDynamoDbStore { return Optional.ofNullable(AttributeValues.getUUID(response.item(), ATTR_ACCOUNT_UUID, null)); } + Optional findE164(final UUID uuid) { + final QueryResponse response = db().query(QueryRequest.builder() + .tableName(tableName) + .indexName(UUID_TO_E164_INDEX_NAME) + .keyConditionExpression("#uuid = :uuid") + .projectionExpression("#e164") + .expressionAttributeNames(Map.of("#uuid", ATTR_ACCOUNT_UUID, + "#e164", KEY_ACCOUNT_E164)) + .expressionAttributeValues(Map.of(":uuid", AttributeValues.fromUUID(uuid))).build()); + + if (response.count() == 0) { + return Optional.empty(); + } + + if (response.count() > 1) { + throw new RuntimeException( + "Impossible result: more than one phone number returned for UUID: " + uuid); + } + + return Optional.ofNullable(response.items().get(0).get(KEY_ACCOUNT_E164).s()); + } + void remove(final String e164) { db().deleteItem(DeleteItemRequest.builder() .tableName(tableName) diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManager.java index 5373246bf..fdb8ba7a8 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManager.java @@ -173,8 +173,7 @@ public class DeletedAccountsManager { } return lockAcquired; - }) - .collect(Collectors.toList()); + }).toList(); assert lockItems.size() == reconciliationCandidates.size(); @@ -192,7 +191,16 @@ public class DeletedAccountsManager { try { deletedAccounts.markReconciled(consumer.reconcile(accountsToReconcile)); } finally { - lockItems.forEach(lockItem -> lockClient.releaseLock(ReleaseLockOptions.builder(lockItem).withBestEffort(true).build())); + lockItems.forEach( + lockItem -> lockClient.releaseLock(ReleaseLockOptions.builder(lockItem).withBestEffort(true).build())); } } + + public Optional findDeletedAccountAci(final String e164) { + return deletedAccounts.findUuid(e164); + } + + public Optional findDeletedAccountE164(final UUID uuid) { + return deletedAccounts.findE164(uuid); + } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java index 883773f6b..fef3d9ef9 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java @@ -14,6 +14,8 @@ import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope; import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity; import org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList; @@ -23,6 +25,8 @@ import org.whispersystems.textsecuregcm.util.Constants; public class MessagesManager { + private static final Logger logger = LoggerFactory.getLogger(MessagesManager.class); + private static final int RESULT_SET_CHUNK_SIZE = 100; private static final MetricRegistry metricRegistry = SharedMetricRegistries.getOrCreate(Constants.METRICS_NAME); @@ -53,7 +57,11 @@ public class MessagesManager { messagesCache.insert(messageGuid, destinationUuid, destinationDevice, message); if (message.hasSource() && !destinationUuid.toString().equals(message.getSourceUuid())) { - reportMessageManager.store(message.getSource(), messageGuid); + if (message.hasSourceUuid()) { + reportMessageManager.store(message.getSource(), message.getSourceUuid(), messageGuid); + } else { + logger.warn("Message missing source UUID"); + } } } diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java index d1bb78d1d..384b7c71f 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java @@ -5,20 +5,23 @@ package org.whispersystems.textsecuregcm.storage; +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + import com.google.common.annotations.VisibleForTesting; import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Timer; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; import org.whispersystems.textsecuregcm.util.AttributeValues; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.GetItemRequest; import software.amazon.awssdk.services.dynamodb.model.GetItemResponse; +import software.amazon.awssdk.services.dynamodb.model.QueryRequest; +import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import software.amazon.awssdk.services.dynamodb.model.ReturnValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse; -import java.util.Map; -import java.util.UUID; - -import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; /** * Manages a global, persistent mapping of phone numbers to phone number identifiers regardless of whether those @@ -31,7 +34,10 @@ public class PhoneNumberIdentifiers { @VisibleForTesting static final String KEY_E164 = "P"; - private static final String ATTR_PHONE_NUMBER_IDENTIFIER = "PNI"; + @VisibleForTesting + static final String INDEX_NAME = "pni_to_p"; + @VisibleForTesting + static final String ATTR_PHONE_NUMBER_IDENTIFIER = "PNI"; private static final Timer GET_PNI_TIMER = Metrics.timer(name(PhoneNumberIdentifiers.class, "get")); private static final Timer SET_PNI_TIMER = Metrics.timer(name(PhoneNumberIdentifiers.class, "set")); @@ -69,6 +75,34 @@ public class PhoneNumberIdentifiers { return phoneNumberIdentifier; } + public Optional getPhoneNumber(final UUID phoneNumberIdentifier) { + final QueryResponse response = dynamoDbClient.query(QueryRequest.builder() + .tableName(tableName) + .indexName(INDEX_NAME) + .keyConditionExpression("#pni = :pni") + .projectionExpression("#phone_number") + .expressionAttributeNames(Map.of( + "#phone_number", KEY_E164, + "#pni", ATTR_PHONE_NUMBER_IDENTIFIER + )) + .expressionAttributeValues(Map.of( + ":pni", AttributeValues.fromUUID(phoneNumberIdentifier) + )) + .build()); + + if (response.count() == 0) { + return Optional.empty(); + } + + if (response.count() > 1) { + throw new RuntimeException( + "Impossible result: more than one phone number returned for PNI: " + phoneNumberIdentifier); + } + + return Optional.ofNullable(response.items().get(0).get(KEY_E164).s()); + } + + @VisibleForTesting UUID generatePhoneNumberIdentifierIfNotExists(final String phoneNumber) { final UpdateItemResponse response = SET_PNI_TIMER.record(() -> dynamoDbClient.updateItem(UpdateItemRequest.builder() diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java index c8713bad0..84fefd25d 100644 --- a/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java +++ b/service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java @@ -1,6 +1,9 @@ package org.whispersystems.textsecuregcm.storage; +import static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name; + import io.lettuce.core.RedisException; +import io.micrometer.core.instrument.Metrics; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -8,6 +11,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +20,8 @@ import org.whispersystems.textsecuregcm.util.UUIDUtil; public class ReportMessageManager { + private static final String MIGRATION_COUNTER_NAME = name(ReportMessageManager.class, "migration"); + private final ReportMessageDynamoDb reportMessageDynamoDb; private final FaultTolerantRedisCluster rateLimitCluster; @@ -39,51 +45,91 @@ public class ReportMessageManager { this.reportedMessageListeners.add(listener); } - public void store(String sourceNumber, UUID messageGuid) { + // TODO sourceNumber can be removed after 2022-04-01 + public void store(String sourceNumber, String sourceAci, UUID messageGuid) { try { Objects.requireNonNull(sourceNumber); + Objects.requireNonNull(sourceAci); reportMessageDynamoDb.store(hash(messageGuid, sourceNumber)); + reportMessageDynamoDb.store(hash(messageGuid, sourceAci)); } catch (final Exception e) { logger.warn("Failed to store hash", e); } } - public void report(String sourceNumber, UUID messageGuid, UUID reporterUuid) { + public void report(Optional sourceNumber, Optional sourceAci, Optional sourcePni, + UUID messageGuid, UUID reporterUuid) { - final boolean found = reportMessageDynamoDb.remove(hash(messageGuid, sourceNumber)); + // TODO sourceNumber can be removed after 2022-04-15 + final boolean foundByNumber = sourceNumber.map(number -> reportMessageDynamoDb.remove(hash(messageGuid, number))) + .orElse(false); - if (found) { + final boolean foundByAci = sourceAci.map(uuid -> reportMessageDynamoDb.remove(hash(messageGuid, uuid.toString()))). + orElse(false); + + if (foundByNumber || foundByAci) { rateLimitCluster.useCluster(connection -> { - final String reportedSenderKey = getReportedSenderKey(sourceNumber); + sourceNumber.ifPresent(number -> { + final String reportedSenderKey = getReportedSenderKey(number); + connection.sync().pfadd(reportedSenderKey, reporterUuid.toString()); + connection.sync().expire(reportedSenderKey, counterTtl.toSeconds()); + }); - connection.sync().pfadd(reportedSenderKey, reporterUuid.toString()); - connection.sync().expire(reportedSenderKey, counterTtl.toSeconds()); + sourcePni.ifPresent(pni -> { + final String reportedSenderKey = getReportedSenderPniKey(pni); + connection.sync().pfadd(reportedSenderKey, reporterUuid.toString()); + connection.sync().expire(reportedSenderKey, counterTtl.toSeconds()); + }); + + sourceAci.ifPresent(aci -> { + final String reportedSenderKey = getReportedSenderAciKey(aci); + connection.sync().pfadd(reportedSenderKey, reporterUuid.toString()); + connection.sync().expire(reportedSenderKey, counterTtl.toSeconds()); + }); }); - reportedMessageListeners.forEach(listener -> { - try { - listener.handleMessageReported(sourceNumber, messageGuid, reporterUuid); - } catch (final Exception e) { - logger.error("Failed to notify listener of reported message", e); - } - }); + sourceNumber.ifPresent(number -> + reportedMessageListeners.forEach(listener -> { + try { + // TODO should listener take the source Aci? + listener.handleMessageReported(number, messageGuid, reporterUuid); + } catch (final Exception e) { + logger.error("Failed to notify listener of reported message", e); + } + })); } + + Metrics.counter( + MIGRATION_COUNTER_NAME, + "foundByNumber", String.valueOf(foundByNumber), + "foundByAci", String.valueOf(foundByAci), + "sourceAciPresent", String.valueOf(sourceAci.isPresent()), + "sourcePniPresent", String.valueOf(sourcePni.isPresent()), + "sourceNumberPresent", String.valueOf(sourceNumber.isPresent()) + ).increment(); } /** - * Returns the number of times messages from the given number have been reported by recipients as abusive. Note that + * Returns the number of times messages from the given account have been reported by recipients as abusive. Note that * this method makes a call to an external service, and callers should take care to memoize calls where possible and * avoid unnecessary calls. * - * @param number the number to check for recent reports - * + * @param account the account to check for recent reports * @return the number of times the given number has been reported recently */ - public int getRecentReportCount(final String number) { + public int getRecentReportCount(final Account account) { try { - return rateLimitCluster.withCluster(connection -> connection.sync().pfcount(getReportedSenderKey(number)).intValue()); + return rateLimitCluster.withCluster( + connection -> + Math.max( + Math.max( + // TODO number can be removed after 2022-04-15 + connection.sync().pfcount(getReportedSenderKey(account.getNumber())).intValue(), + connection.sync().pfcount(getReportedSenderPniKey(account.getPhoneNumberIdentifier())) + .intValue()), + connection.sync().pfcount(getReportedSenderAciKey(account.getUuid())).intValue())); } catch (final RedisException e) { return 0; } @@ -106,4 +152,12 @@ public class ReportMessageManager { private static String getReportedSenderKey(final String senderNumber) { return "reported_number::" + senderNumber; } + + private static String getReportedSenderAciKey(final UUID aci) { + return "reported_account::" + aci.toString(); + } + + private static String getReportedSenderPniKey(final UUID pni) { + return "reported_pni::" + pni.toString(); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java index 2d9ad5c37..a3441f88f 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java @@ -80,6 +80,7 @@ import org.whispersystems.textsecuregcm.push.MessageSender; import org.whispersystems.textsecuregcm.push.ReceiptSender; import org.whispersystems.textsecuregcm.storage.Account; import org.whispersystems.textsecuregcm.storage.AccountsManager; +import org.whispersystems.textsecuregcm.storage.DeletedAccountsManager; import org.whispersystems.textsecuregcm.storage.Device; import org.whispersystems.textsecuregcm.storage.MessagesManager; import org.whispersystems.textsecuregcm.storage.ReportMessageManager; @@ -94,24 +95,25 @@ class MessageControllerTest { private static final UUID SINGLE_DEVICE_UUID = UUID.randomUUID(); private static final UUID SINGLE_DEVICE_PNI = UUID.randomUUID(); - private static final String MULTI_DEVICE_RECIPIENT = "+14152222222"; - private static final UUID MULTI_DEVICE_UUID = UUID.randomUUID(); + private static final String MULTI_DEVICE_RECIPIENT = "+14152222222"; + private static final UUID MULTI_DEVICE_UUID = UUID.randomUUID(); private static final String INTERNATIONAL_RECIPIENT = "+61123456789"; - private static final UUID INTERNATIONAL_UUID = UUID.randomUUID(); + private static final UUID INTERNATIONAL_UUID = UUID.randomUUID(); private Account internationalAccount; @SuppressWarnings("unchecked") private static final RedisAdvancedClusterCommands redisCommands = mock(RedisAdvancedClusterCommands.class); - private static final MessageSender messageSender = mock(MessageSender.class); - private static final ReceiptSender receiptSender = mock(ReceiptSender.class); - private static final AccountsManager accountsManager = mock(AccountsManager.class); - private static final MessagesManager messagesManager = mock(MessagesManager.class); - private static final RateLimiters rateLimiters = mock(RateLimiters.class); - private static final RateLimiter rateLimiter = mock(RateLimiter.class); - private static final ApnFallbackManager apnFallbackManager = mock(ApnFallbackManager.class); + private static final MessageSender messageSender = mock(MessageSender.class); + private static final ReceiptSender receiptSender = mock(ReceiptSender.class); + private static final AccountsManager accountsManager = mock(AccountsManager.class); + private static final DeletedAccountsManager deletedAccountsManager = mock(DeletedAccountsManager.class); + private static final MessagesManager messagesManager = mock(MessagesManager.class); + private static final RateLimiters rateLimiters = mock(RateLimiters.class); + private static final RateLimiter rateLimiter = mock(RateLimiter.class); + private static final ApnFallbackManager apnFallbackManager = mock(ApnFallbackManager.class); private static final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class); private static final ExecutorService multiRecipientMessageExecutor = mock(ExecutorService.class); @@ -122,8 +124,9 @@ class MessageControllerTest { .addProvider(RateLimitExceededExceptionMapper.class) .addProvider(MultiDeviceMessageListProvider.class) .setTestContainerFactory(new GrizzlyWebTestContainerFactory()) - .addResource(new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, - messagesManager, apnFallbackManager, reportMessageManager, multiRecipientMessageExecutor)) + .addResource( + new MessageController(rateLimiters, messageSender, receiptSender, accountsManager, deletedAccountsManager, + messagesManager, apnFallbackManager, reportMessageManager, multiRecipientMessageExecutor)) .build(); @BeforeEach @@ -486,13 +489,13 @@ class MessageControllerTest { } @Test - void testGetMessages() throws Exception { + void testGetMessages() { final long timestampOne = 313377; final long timestampTwo = 313388; final UUID messageGuidOne = UUID.randomUUID(); - final UUID sourceUuid = UUID.randomUUID(); + final UUID sourceUuid = UUID.randomUUID(); List messages = new LinkedList<>() {{ add(new OutgoingMessageEntity(messageGuidOne, Envelope.Type.CIPHERTEXT_VALUE, timestampOne, "+14152222222", sourceUuid, 2, AuthHelper.VALID_UUID, "hi there".getBytes(), 0)); @@ -595,12 +598,23 @@ class MessageControllerTest { } @Test - void testReportMessage() { + void testReportMessageByE164() { final String senderNumber = "+12125550001"; - final UUID messageGuid = UUID.randomUUID(); + final UUID senderAci = UUID.randomUUID(); + final UUID senderPni = UUID.randomUUID(); + UUID messageGuid = UUID.randomUUID(); - final Response response = + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(senderAci); + when(account.getNumber()).thenReturn(senderNumber); + when(account.getPhoneNumberIdentifier()).thenReturn(senderPni); + + when(accountsManager.getByE164(senderNumber)).thenReturn(Optional.of(account)); + when(deletedAccountsManager.findDeletedAccountAci(senderNumber)).thenReturn(Optional.of(senderAci)); + when(accountsManager.getPhoneNumberIdentifier(senderNumber)).thenReturn(senderPni); + + Response response = resources.getJerseyTest() .target(String.format("/v1/messages/report/%s/%s", senderNumber, messageGuid)) .request() @@ -609,7 +623,73 @@ class MessageControllerTest { assertThat(response.getStatus(), is(equalTo(202))); - verify(reportMessageManager).report(senderNumber, messageGuid, AuthHelper.VALID_UUID); + verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), + messageGuid, AuthHelper.VALID_UUID); + verify(deletedAccountsManager, never()).findDeletedAccountE164(any(UUID.class)); + verify(accountsManager, never()).getPhoneNumberIdentifier(anyString()); + + when(accountsManager.getByE164(senderNumber)).thenReturn(Optional.empty()); + messageGuid = UUID.randomUUID(); + + response = + resources.getJerseyTest() + .target(String.format("/v1/messages/report/%s/%s", senderNumber, messageGuid)) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(null); + + assertThat(response.getStatus(), is(equalTo(202))); + + verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), + messageGuid, AuthHelper.VALID_UUID); + } + + @Test + void testReportMessageByAci() { + + final String senderNumber = "+12125550001"; + final UUID senderAci = UUID.randomUUID(); + final UUID senderPni = UUID.randomUUID(); + UUID messageGuid = UUID.randomUUID(); + + final Account account = mock(Account.class); + when(account.getUuid()).thenReturn(senderAci); + when(account.getNumber()).thenReturn(senderNumber); + when(account.getPhoneNumberIdentifier()).thenReturn(senderPni); + + when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.of(account)); + when(deletedAccountsManager.findDeletedAccountE164(senderAci)).thenReturn(Optional.of(senderNumber)); + when(accountsManager.getPhoneNumberIdentifier(senderNumber)).thenReturn(senderPni); + + Response response = + resources.getJerseyTest() + .target(String.format("/v1/messages/report/%s/%s", senderAci, messageGuid)) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(null); + + assertThat(response.getStatus(), is(equalTo(202))); + + verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), + messageGuid, AuthHelper.VALID_UUID); + verify(deletedAccountsManager, never()).findDeletedAccountE164(any(UUID.class)); + verify(accountsManager, never()).getPhoneNumberIdentifier(anyString()); + + when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.empty()); + + messageGuid = UUID.randomUUID(); + + response = + resources.getJerseyTest() + .target(String.format("/v1/messages/report/%s/%s", senderAci, messageGuid)) + .request() + .header("Authorization", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)) + .post(null); + + assertThat(response.getStatus(), is(equalTo(202))); + + verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni), + messageGuid, AuthHelper.VALID_UUID); } @ParameterizedTest diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManagerTest.java index 1d706bac3..128b07216 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsManagerTest.java @@ -5,6 +5,16 @@ package org.whispersystems.textsecuregcm.storage; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.Thread.State; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -17,16 +27,6 @@ import software.amazon.awssdk.services.dynamodb.model.Projection; import software.amazon.awssdk.services.dynamodb.model.ProjectionType; import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; -import java.lang.Thread.State; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; class DeletedAccountsManagerTest { @@ -43,11 +43,26 @@ class DeletedAccountsManagerTest { .attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) .attributeType(ScalarAttributeType.N) .build()) + .attributeDefinition(AttributeDefinition.builder() + .attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID) + .attributeType(ScalarAttributeType.B) + .build()) .globalSecondaryIndex(GlobalSecondaryIndex.builder() .indexName(NEEDS_RECONCILIATION_INDEX_NAME) - .keySchema(KeySchemaElement.builder().attributeName(DeletedAccounts.KEY_ACCOUNT_E164).keyType(KeyType.HASH).build(), - KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION).keyType(KeyType.RANGE).build()) - .projection(Projection.builder().projectionType(ProjectionType.INCLUDE).nonKeyAttributes(DeletedAccounts.ATTR_ACCOUNT_UUID).build()) + .keySchema( + KeySchemaElement.builder().attributeName(DeletedAccounts.KEY_ACCOUNT_E164).keyType(KeyType.HASH).build(), + KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) + .keyType(KeyType.RANGE).build()) + .projection(Projection.builder().projectionType(ProjectionType.INCLUDE) + .nonKeyAttributes(DeletedAccounts.ATTR_ACCOUNT_UUID).build()) + .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) + .build()) + .globalSecondaryIndex(GlobalSecondaryIndex.builder() + .indexName(DeletedAccounts.UUID_TO_E164_INDEX_NAME) + .keySchema( + KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID).keyType(KeyType.HASH).build() + ) + .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build()) .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) .build()) .build(); @@ -167,4 +182,5 @@ class DeletedAccountsManagerTest { return List.of(deletedAccounts.get(0).second()); }); } + } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTest.java index 3360d004d..a48b161db 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/DeletedAccountsTest.java @@ -41,11 +41,26 @@ class DeletedAccountsTest { .attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) .attributeType(ScalarAttributeType.N) .build()) + .attributeDefinition(AttributeDefinition.builder() + .attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID) + .attributeType(ScalarAttributeType.B) + .build()) .globalSecondaryIndex(GlobalSecondaryIndex.builder() .indexName(NEEDS_RECONCILIATION_INDEX_NAME) - .keySchema(KeySchemaElement.builder().attributeName(DeletedAccounts.KEY_ACCOUNT_E164).keyType(KeyType.HASH).build(), - KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION).keyType(KeyType.RANGE).build()) - .projection(Projection.builder().projectionType(ProjectionType.INCLUDE).nonKeyAttributes(DeletedAccounts.ATTR_ACCOUNT_UUID).build()) + .keySchema( + KeySchemaElement.builder().attributeName(DeletedAccounts.KEY_ACCOUNT_E164).keyType(KeyType.HASH).build(), + KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_NEEDS_CDS_RECONCILIATION) + .keyType(KeyType.RANGE).build()) + .projection(Projection.builder().projectionType(ProjectionType.INCLUDE) + .nonKeyAttributes(DeletedAccounts.ATTR_ACCOUNT_UUID).build()) + .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) + .build()) + .globalSecondaryIndex(GlobalSecondaryIndex.builder() + .indexName(DeletedAccounts.UUID_TO_E164_INDEX_NAME) + .keySchema( + KeySchemaElement.builder().attributeName(DeletedAccounts.ATTR_ACCOUNT_UUID).keyType(KeyType.HASH).build() + ) + .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build()) .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) .build()) .build(); @@ -156,4 +171,30 @@ class DeletedAccountsTest { assertEquals(expectedAccountsNeedingReconciliation, accountsNeedingReconciliation); } + + + @Test + void testFindE164() { + assertEquals(Optional.empty(), deletedAccounts.findE164(UUID.randomUUID())); + + final UUID uuid = UUID.randomUUID(); + final String e164 = "+18005551234"; + + deletedAccounts.put(uuid, e164, true); + + assertEquals(Optional.of(e164), deletedAccounts.findE164(uuid)); + } + + @Test + void testFindUUID() { + final String e164 = "+18005551234"; + + assertEquals(Optional.empty(), deletedAccounts.findUuid(e164)); + + final UUID uuid = UUID.randomUUID(); + + deletedAccounts.put(uuid, e164, true); + + assertEquals(Optional.of(uuid), deletedAccounts.findUuid(e164)); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java index fe1ec5e73..f98759cf9 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java @@ -24,16 +24,17 @@ class MessagesManagerTest { @Test void insert() { final String sourceNumber = "+12025551212"; + final UUID sourceAci = UUID.randomUUID(); final Envelope message = Envelope.newBuilder() .setSource(sourceNumber) - .setSourceUuid(UUID.randomUUID().toString()) + .setSourceUuid(sourceAci.toString()) .build(); final UUID destinationUuid = UUID.randomUUID(); messagesManager.insert(destinationUuid, 1L, message); - verify(reportMessageManager).store(eq(sourceNumber), any(UUID.class)); + verify(reportMessageManager).store(eq(sourceNumber), eq(sourceAci.toString()), any(UUID.class)); final Envelope syncMessage = Envelope.newBuilder(message) .setSourceUuid(destinationUuid.toString()) diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java index d7caca1b0..edb858ac9 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java @@ -6,13 +6,21 @@ package org.whispersystems.textsecuregcm.storage; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition; +import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex; +import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement; +import software.amazon.awssdk.services.dynamodb.model.KeyType; +import software.amazon.awssdk.services.dynamodb.model.Projection; +import software.amazon.awssdk.services.dynamodb.model.ProjectionType; +import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput; import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType; class PhoneNumberIdentifiersTest { @@ -27,6 +35,20 @@ class PhoneNumberIdentifiersTest { .attributeName(PhoneNumberIdentifiers.KEY_E164) .attributeType(ScalarAttributeType.S) .build()) + .attributeDefinition(AttributeDefinition.builder() + .attributeName(PhoneNumberIdentifiers.ATTR_PHONE_NUMBER_IDENTIFIER) + .attributeType(ScalarAttributeType.B) + .build()) + .globalSecondaryIndex(GlobalSecondaryIndex.builder() + .indexName(PhoneNumberIdentifiers.INDEX_NAME) + .projection(Projection.builder() + .projectionType(ProjectionType.KEYS_ONLY) + .build()) + .keySchema(KeySchemaElement.builder().keyType(KeyType.HASH) + .attributeName(PhoneNumberIdentifiers.ATTR_PHONE_NUMBER_IDENTIFIER) + .build()) + .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build()) + .build()) .build(); private PhoneNumberIdentifiers phoneNumberIdentifiers; @@ -55,4 +77,14 @@ class PhoneNumberIdentifiersTest { assertEquals(phoneNumberIdentifiers.generatePhoneNumberIdentifierIfNotExists(number), phoneNumberIdentifiers.generatePhoneNumberIdentifierIfNotExists(number)); } + + @Test + void getPhoneNumber() { + final String number = "+18005551234"; + + assertFalse(phoneNumberIdentifiers.getPhoneNumber(UUID.randomUUID()).isPresent()); + + final UUID pni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number); + assertEquals(Optional.of(number), phoneNumberIdentifiers.getPhoneNumber(pni)); + } } diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java index b0bae411e..6efc7db98 100644 --- a/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java +++ b/service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java @@ -6,14 +6,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import io.micrometer.core.instrument.Counter; -import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.time.Duration; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -23,8 +22,16 @@ import org.whispersystems.textsecuregcm.redis.RedisClusterExtension; class ReportMessageManagerTest { private ReportMessageDynamoDb reportMessageDynamoDb; + private ReportMessageManager reportMessageManager; + private String sourceNumber; + private UUID sourceAci; + private UUID sourcePni; + private Account sourceAccount; + private UUID messageGuid; + private UUID reporterUuid; + @RegisterExtension static RedisClusterExtension RATE_LIMIT_CLUSTER_EXTENSION = RedisClusterExtension.builder().build(); @@ -34,77 +41,95 @@ class ReportMessageManagerTest { reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, RATE_LIMIT_CLUSTER_EXTENSION.getRedisCluster(), Duration.ofDays(1)); + + sourceNumber = "+15105551111"; + sourceAci = UUID.randomUUID(); + sourcePni = UUID.randomUUID(); + messageGuid = UUID.randomUUID(); + reporterUuid = UUID.randomUUID(); + + sourceAccount = mock(Account.class); + when(sourceAccount.getUuid()).thenReturn(sourceAci); + when(sourceAccount.getNumber()).thenReturn(sourceNumber); + when(sourceAccount.getPhoneNumberIdentifier()).thenReturn(sourcePni); } @Test void testStore() { - - final UUID messageGuid = UUID.randomUUID(); - final String number = "+15105551111"; - - assertDoesNotThrow(() -> reportMessageManager.store(null, messageGuid)); + assertDoesNotThrow(() -> reportMessageManager.store(null, null, messageGuid)); verifyNoInteractions(reportMessageDynamoDb); - reportMessageManager.store(number, messageGuid); + reportMessageManager.store(sourceNumber, sourceAci.toString(), messageGuid); - verify(reportMessageDynamoDb).store(any()); + verify(reportMessageDynamoDb, times(2)).store(any()); doThrow(RuntimeException.class) .when(reportMessageDynamoDb).store(any()); - assertDoesNotThrow(() -> reportMessageManager.store(number, messageGuid)); + assertDoesNotThrow(() -> reportMessageManager.store(sourceNumber, sourceAci.toString(), messageGuid)); } @Test void testReport() { - final String sourceNumber = "+15105551111"; - final UUID messageGuid = UUID.randomUUID(); - final UUID reporterUuid = UUID.randomUUID(); - final ReportedMessageListener listener = mock(ReportedMessageListener.class); reportMessageManager.addListener(listener); when(reportMessageDynamoDb.remove(any())).thenReturn(false); - reportMessageManager.report(sourceNumber, messageGuid, reporterUuid); + reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), messageGuid, + reporterUuid); - assertEquals(0, reportMessageManager.getRecentReportCount(sourceNumber)); + assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); when(reportMessageDynamoDb.remove(any())).thenReturn(true); - reportMessageManager.report(sourceNumber, messageGuid, reporterUuid); + reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), messageGuid, + reporterUuid); - assertEquals(1, reportMessageManager.getRecentReportCount(sourceNumber)); + assertEquals(1, reportMessageManager.getRecentReportCount(sourceAccount)); verify(listener).handleMessageReported(sourceNumber, messageGuid, reporterUuid); } @Test void testReportMultipleReporters() { - final String sourceNumber = "+15105551111"; - final UUID messageGuid = UUID.randomUUID(); - when(reportMessageDynamoDb.remove(any())).thenReturn(true); - assertEquals(0, reportMessageManager.getRecentReportCount(sourceNumber)); + assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); for (int i = 0; i < 100; i++) { - reportMessageManager.report(sourceNumber, messageGuid, UUID.randomUUID()); + reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), + messageGuid, UUID.randomUUID()); } - assertTrue(reportMessageManager.getRecentReportCount(sourceNumber) > 10); + assertTrue(reportMessageManager.getRecentReportCount(sourceAccount) > 10); } @Test void testReportSingleReporter() { - final String sourceNumber = "+15105551111"; - final UUID messageGuid = UUID.randomUUID(); - final UUID reporterUuid = UUID.randomUUID(); - when(reportMessageDynamoDb.remove(any())).thenReturn(true); - assertEquals(0, reportMessageManager.getRecentReportCount(sourceNumber)); + assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); for (int i = 0; i < 100; i++) { - reportMessageManager.report(sourceNumber, messageGuid, reporterUuid); + reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), + messageGuid, + reporterUuid); } - assertEquals(1, reportMessageManager.getRecentReportCount(sourceNumber)); + assertEquals(1, reportMessageManager.getRecentReportCount(sourceAccount)); + } + + @Test + void testReportMultipleReportersByPni() { + when(reportMessageDynamoDb.remove(any())).thenReturn(true); + assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount)); + + for (int i = 0; i < 100; i++) { + reportMessageManager.report(Optional.of(sourceNumber), Optional.empty(), Optional.of(sourcePni), + messageGuid, UUID.randomUUID()); + } + + reportMessageManager.report(Optional.empty(), Optional.of(sourceAci), Optional.empty(), + messageGuid, UUID.randomUUID()); + + final int recentReportCount = reportMessageManager.getRecentReportCount(sourceAccount); + assertTrue(recentReportCount > 10); } }